| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/url" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func TestHasMailboxClassification(t *testing.T) { |
| 12 | fk := newFakeKeymail(t, "paul@keymail.dev") |
| 13 | k := fedClient(t, fk) |
| 14 | fk.lookupStatus["a@example.com"] = 200 |
| 15 | fk.lookupStatus["b@example.com"] = 400 |
| 16 | fk.lookupStatus["c@example.com"] = 404 |
| 17 | fk.lookupStatus["d@example.com"] = 409 |
| 18 | fk.lookupStatus["e@example.com"] = 500 |
| 19 | fk.lookupStatus["f@example.com"] = 503 |
| 20 | cases := []struct { |
| 21 | addr string |
| 22 | want bool |
| 23 | unreachable bool |
| 24 | }{ |
| 25 | {"a@example.com", true, false}, |
| 26 | {"b@example.com", false, false}, |
| 27 | {"c@example.com", false, false}, |
| 28 | {"d@example.com", false, false}, |
| 29 | {"e@example.com", false, true}, |
| 30 | {"f@example.com", false, true}, |
| 31 | {"sam@keymail.dev", true, false}, // the fake's ordinary answer |
| 32 | } |
| 33 | for _, c := range cases { |
| 34 | got, err := k.HasMailbox(context.Background(), c.addr) |
| 35 | if got != c.want || errors.Is(err, ErrKeymailUnreachable) != c.unreachable || (err != nil) != c.unreachable { |
| 36 | t.Errorf("HasMailbox(%s) = %v, %v; want %v, unreachable=%v", c.addr, got, err, c.want, c.unreachable) |
| 37 | } |
| 38 | } |
| 39 | // A lookup that stalls past the caller's deadline is unreachable, not a no. |
| 40 | fk.lookupStall["slow@example.com"] = 3 * time.Second |
| 41 | fk.lookupStatus["slow@example.com"] = 200 |
| 42 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| 43 | defer cancel() |
| 44 | start := time.Now() |
| 45 | got, err := k.HasMailbox(ctx, "slow@example.com") |
| 46 | if got || !errors.Is(err, ErrKeymailUnreachable) { |
| 47 | t.Errorf("stalled HasMailbox = %v, %v; want false, ErrKeymailUnreachable", got, err) |
| 48 | } |
| 49 | if d := time.Since(start); d > 2*time.Second { |
| 50 | t.Errorf("stalled HasMailbox took %v", d) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | func TestKeymailHost(t *testing.T) { |
| 55 | for in, want := range map[string]string{ |
| 56 | "https://keymail.dev": "keymail.dev", |
| 57 | "https://keymail.dev/": "keymail.dev", |
| 58 | "http://127.0.0.1:8123": "127.0.0.1", |
| 59 | "https://Keymail.Dev:8443": "keymail.dev", |
| 60 | } { |
| 61 | if got := NewKeymail(in, "https://console.example").Host(); got != want { |
| 62 | t.Errorf("Host(%q) = %q, want %q", in, got, want) |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestSendFederatedClassifiesLookupErrors(t *testing.T) { |
| 68 | fk := newFakeKeymail(t, "paul@keymail.dev") |
| 69 | fk.lookupStatus["a@example.com"] = 409 |
| 70 | fk.lookupStatus["b@example.com"] = 500 |
| 71 | k := fedClient(t, fk) |
| 72 | if _, err := k.SendFederated(context.Background(), "a@example.com", "s", "b"); !errors.Is(err, ErrNoKeymailMailbox) || errors.Is(err, ErrKeymailUnreachable) { |
| 73 | t.Errorf("409: err = %v, want ErrNoKeymailMailbox only", err) |
| 74 | } |
| 75 | _, err := k.SendFederated(context.Background(), "b@example.com", "s", "b") |
| 76 | if !errors.Is(err, ErrKeymailUnreachable) || errors.Is(err, ErrNoKeymailMailbox) { |
| 77 | t.Errorf("500: err = %v, want ErrKeymailUnreachable only", err) |
| 78 | } |
| 79 | var ke *keymailError |
| 80 | if !errors.As(err, &ke) || ke.Status != 500 { |
| 81 | t.Errorf("500: err = %v, want the keymailError wrapped too", err) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // deliverFixture builds a Server whose federation leg is the real |
| 86 | // SendFederated against the fake Keymail, with a recording mailer. |
| 87 | func deliverFixture(t *testing.T) (*consoleFixture, *fakeConsoleMail) { |
| 88 | t.Helper() |
| 89 | fx := newConsoleFixture(t) |
| 90 | mailer := &fakeConsoleMail{ready: true} |
| 91 | fx.srv.consoleMail = mailer |
| 92 | return fx, mailer |
| 93 | } |
| 94 | |
| 95 | func TestDeliverChain(t *testing.T) { |
| 96 | kmHost := func(fx *consoleFixture) string { |
| 97 | u, _ := url.Parse(fx.fk.srv.URL) |
| 98 | return u.Hostname() |
| 99 | } |
| 100 | cases := []struct { |
| 101 | name string |
| 102 | to func(fx *consoleFixture) string |
| 103 | setup func(fx *consoleFixture, to string) |
| 104 | noMailer bool |
| 105 | wantChannel deliveryChannel |
| 106 | wantErr func(error) bool |
| 107 | wantFed bool |
| 108 | wantMail bool |
| 109 | }{ |
| 110 | {name: "no mailbox (404) goes to the mailer", |
| 111 | to: func(*consoleFixture) string { return "ghost@keymail.dev" }, |
| 112 | setup: func(fx *consoleFixture, _ string) { fx.fk.noMailbox["ghost"] = true }, |
| 113 | wantChannel: "email", wantMail: true}, |
| 114 | {name: "foreign domain (400) goes to the mailer", |
| 115 | to: func(*consoleFixture) string { return "x@example.com" }, |
| 116 | wantChannel: "email", wantMail: true}, |
| 117 | {name: "unclaimed (409) goes to the mailer", |
| 118 | to: func(*consoleFixture) string { return "x@example.com" }, |
| 119 | setup: func(fx *consoleFixture, to string) { fx.fk.lookupStatus[to] = 409 }, |
| 120 | wantChannel: "email", wantMail: true}, |
| 121 | {name: "federation delivers, mailer untouched", |
| 122 | to: func(*consoleFixture) string { return "sam@keymail.dev" }, |
| 123 | wantChannel: "keymail", wantFed: true}, |
| 124 | {name: "blocked stops", |
| 125 | to: func(*consoleFixture) string { return "sam@keymail.dev" }, |
| 126 | setup: func(fx *consoleFixture, _ string) { fx.fk.blocked["console@"+OriginHost(fx.ts.URL)] = true }, |
| 127 | wantErr: IsKeymailBlocked}, |
| 128 | {name: "price too high stops", |
| 129 | to: func(*consoleFixture) string { return "sam@keymail.dev" }, |
| 130 | setup: func(fx *consoleFixture, _ string) { fx.fk.powBits = maxFederationPowBits + 1 }, |
| 131 | wantErr: func(err error) bool { return errors.Is(err, ErrFederationPriceTooHigh) }}, |
| 132 | // The lookup said the mailbox exists, then the delivery itself failed |
| 133 | // (neither blocked nor price): still a Keymail address, never SES. |
| 134 | {name: "delivery 500 after a good lookup stops", |
| 135 | to: func(*consoleFixture) string { return "sam@keymail.dev" }, |
| 136 | setup: func(fx *consoleFixture, _ string) { fx.fk.deliverStatus = 500 }, |
| 137 | wantErr: func(err error) bool { var ke *keymailError; return errors.As(err, &ke) && ke.Status == 500 }}, |
| 138 | {name: "delivery 500 for a custom domain with a mailbox stops", |
| 139 | to: func(*consoleFixture) string { return "x@example.com" }, |
| 140 | setup: func(fx *consoleFixture, to string) { |
| 141 | fx.fk.lookupStatus[to] = 200 |
| 142 | fx.fk.deliverStatus = 500 |
| 143 | }, |
| 144 | wantErr: func(err error) bool { var ke *keymailError; return errors.As(err, &ke) && ke.Status == 500 }}, |
| 145 | {name: "lookup 500 for the keymail host stops", |
| 146 | to: func(fx *consoleFixture) string { return "x@" + kmHost(fx) }, |
| 147 | setup: func(fx *consoleFixture, to string) { fx.fk.lookupStatus[to] = 500 }, |
| 148 | wantErr: func(err error) bool { return errors.Is(err, ErrKeymailUnreachable) }}, |
| 149 | {name: "lookup 500 for another domain goes to the mailer", |
| 150 | to: func(*consoleFixture) string { return "x@example.com" }, |
| 151 | setup: func(fx *consoleFixture, to string) { fx.fk.lookupStatus[to] = 500 }, |
| 152 | wantChannel: "email", wantMail: true}, |
| 153 | {name: "no mailer is an error", |
| 154 | to: func(*consoleFixture) string { return "x@example.com" }, |
| 155 | noMailer: true, |
| 156 | wantErr: func(err error) bool { return err != nil }}, |
| 157 | } |
| 158 | for _, c := range cases { |
| 159 | t.Run(c.name, func(t *testing.T) { |
| 160 | fx, mailer := deliverFixture(t) |
| 161 | to := c.to(fx) |
| 162 | if c.setup != nil { |
| 163 | c.setup(fx, to) |
| 164 | } |
| 165 | if c.noMailer { |
| 166 | fx.srv.consoleMail = nil |
| 167 | } |
| 168 | ch, err := fx.srv.deliver(context.Background(), to, "subj", "the body", 5*time.Second) |
| 169 | if c.wantErr != nil { |
| 170 | if err == nil || !c.wantErr(err) { |
| 171 | t.Fatalf("err = %v, want the case's error", err) |
| 172 | } |
| 173 | if ch != "" { |
| 174 | t.Errorf("channel = %q on error", ch) |
| 175 | } |
| 176 | } else { |
| 177 | if err != nil { |
| 178 | t.Fatalf("err = %v", err) |
| 179 | } |
| 180 | if ch != c.wantChannel { |
| 181 | t.Errorf("channel = %q, want %q", ch, c.wantChannel) |
| 182 | } |
| 183 | } |
| 184 | if got := len(fx.fk.fedBodies()) > 0; got != c.wantFed { |
| 185 | t.Errorf("federated = %v, want %v", got, c.wantFed) |
| 186 | } |
| 187 | if got := len(mailer.sentCopy()) > 0; got != c.wantMail { |
| 188 | t.Errorf("mailed = %v, want %v", got, c.wantMail) |
| 189 | } |
| 190 | }) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // The keymailShare bounds Keymail's slice of the budget: a stalled lookup for |
| 195 | // a foreign domain leaves time for the mailer. |
| 196 | func TestDeliverKeymailShareLeavesTimeForTheMailer(t *testing.T) { |
| 197 | fx, mailer := deliverFixture(t) |
| 198 | fx.fk.lookupStall["x@example.com"] = 3 * time.Second |
| 199 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 200 | defer cancel() |
| 201 | start := time.Now() |
| 202 | ch, err := fx.srv.deliver(ctx, "x@example.com", "s", "b", 200*time.Millisecond) |
| 203 | if err != nil || ch != "email" { |
| 204 | t.Fatalf("deliver = %q, %v; want email", ch, err) |
| 205 | } |
| 206 | if d := time.Since(start); d > time.Second { |
| 207 | t.Errorf("deliver took %v", d) |
| 208 | } |
| 209 | if len(mailer.sentCopy()) != 1 { |
| 210 | t.Errorf("mailer calls = %v", mailer.sentCopy()) |
| 211 | } |
| 212 | } |
| 213 | |