package console import ( "context" "errors" "net/url" "testing" "time" ) func TestHasMailboxClassification(t *testing.T) { fk := newFakeKeymail(t, "paul@keymail.dev") k := fedClient(t, fk) fk.lookupStatus["a@example.com"] = 200 fk.lookupStatus["b@example.com"] = 400 fk.lookupStatus["c@example.com"] = 404 fk.lookupStatus["d@example.com"] = 409 fk.lookupStatus["e@example.com"] = 500 fk.lookupStatus["f@example.com"] = 503 cases := []struct { addr string want bool unreachable bool }{ {"a@example.com", true, false}, {"b@example.com", false, false}, {"c@example.com", false, false}, {"d@example.com", false, false}, {"e@example.com", false, true}, {"f@example.com", false, true}, {"sam@keymail.dev", true, false}, // the fake's ordinary answer } for _, c := range cases { got, err := k.HasMailbox(context.Background(), c.addr) if got != c.want || errors.Is(err, ErrKeymailUnreachable) != c.unreachable || (err != nil) != c.unreachable { t.Errorf("HasMailbox(%s) = %v, %v; want %v, unreachable=%v", c.addr, got, err, c.want, c.unreachable) } } // A lookup that stalls past the caller's deadline is unreachable, not a no. fk.lookupStall["slow@example.com"] = 3 * time.Second fk.lookupStatus["slow@example.com"] = 200 ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() start := time.Now() got, err := k.HasMailbox(ctx, "slow@example.com") if got || !errors.Is(err, ErrKeymailUnreachable) { t.Errorf("stalled HasMailbox = %v, %v; want false, ErrKeymailUnreachable", got, err) } if d := time.Since(start); d > 2*time.Second { t.Errorf("stalled HasMailbox took %v", d) } } func TestKeymailHost(t *testing.T) { for in, want := range map[string]string{ "https://keymail.dev": "keymail.dev", "https://keymail.dev/": "keymail.dev", "http://127.0.0.1:8123": "127.0.0.1", "https://Keymail.Dev:8443": "keymail.dev", } { if got := NewKeymail(in, "https://console.example").Host(); got != want { t.Errorf("Host(%q) = %q, want %q", in, got, want) } } } func TestSendFederatedClassifiesLookupErrors(t *testing.T) { fk := newFakeKeymail(t, "paul@keymail.dev") fk.lookupStatus["a@example.com"] = 409 fk.lookupStatus["b@example.com"] = 500 k := fedClient(t, fk) if _, err := k.SendFederated(context.Background(), "a@example.com", "s", "b"); !errors.Is(err, ErrNoKeymailMailbox) || errors.Is(err, ErrKeymailUnreachable) { t.Errorf("409: err = %v, want ErrNoKeymailMailbox only", err) } _, err := k.SendFederated(context.Background(), "b@example.com", "s", "b") if !errors.Is(err, ErrKeymailUnreachable) || errors.Is(err, ErrNoKeymailMailbox) { t.Errorf("500: err = %v, want ErrKeymailUnreachable only", err) } var ke *keymailError if !errors.As(err, &ke) || ke.Status != 500 { t.Errorf("500: err = %v, want the keymailError wrapped too", err) } } // deliverFixture builds a Server whose federation leg is the real // SendFederated against the fake Keymail, with a recording mailer. func deliverFixture(t *testing.T) (*consoleFixture, *fakeConsoleMail) { t.Helper() fx := newConsoleFixture(t) mailer := &fakeConsoleMail{ready: true} fx.srv.consoleMail = mailer return fx, mailer } func TestDeliverChain(t *testing.T) { kmHost := func(fx *consoleFixture) string { u, _ := url.Parse(fx.fk.srv.URL) return u.Hostname() } cases := []struct { name string to func(fx *consoleFixture) string setup func(fx *consoleFixture, to string) noMailer bool wantChannel deliveryChannel wantErr func(error) bool wantFed bool wantMail bool }{ {name: "no mailbox (404) goes to the mailer", to: func(*consoleFixture) string { return "ghost@keymail.dev" }, setup: func(fx *consoleFixture, _ string) { fx.fk.noMailbox["ghost"] = true }, wantChannel: "email", wantMail: true}, {name: "foreign domain (400) goes to the mailer", to: func(*consoleFixture) string { return "x@example.com" }, wantChannel: "email", wantMail: true}, {name: "unclaimed (409) goes to the mailer", to: func(*consoleFixture) string { return "x@example.com" }, setup: func(fx *consoleFixture, to string) { fx.fk.lookupStatus[to] = 409 }, wantChannel: "email", wantMail: true}, {name: "federation delivers, mailer untouched", to: func(*consoleFixture) string { return "sam@keymail.dev" }, wantChannel: "keymail", wantFed: true}, {name: "blocked stops", to: func(*consoleFixture) string { return "sam@keymail.dev" }, setup: func(fx *consoleFixture, _ string) { fx.fk.blocked["console@"+OriginHost(fx.ts.URL)] = true }, wantErr: IsKeymailBlocked}, {name: "price too high stops", to: func(*consoleFixture) string { return "sam@keymail.dev" }, setup: func(fx *consoleFixture, _ string) { fx.fk.powBits = maxFederationPowBits + 1 }, wantErr: func(err error) bool { return errors.Is(err, ErrFederationPriceTooHigh) }}, // The lookup said the mailbox exists, then the delivery itself failed // (neither blocked nor price): still a Keymail address, never SES. {name: "delivery 500 after a good lookup stops", to: func(*consoleFixture) string { return "sam@keymail.dev" }, setup: func(fx *consoleFixture, _ string) { fx.fk.deliverStatus = 500 }, wantErr: func(err error) bool { var ke *keymailError; return errors.As(err, &ke) && ke.Status == 500 }}, {name: "delivery 500 for a custom domain with a mailbox stops", to: func(*consoleFixture) string { return "x@example.com" }, setup: func(fx *consoleFixture, to string) { fx.fk.lookupStatus[to] = 200 fx.fk.deliverStatus = 500 }, wantErr: func(err error) bool { var ke *keymailError; return errors.As(err, &ke) && ke.Status == 500 }}, {name: "lookup 500 for the keymail host stops", to: func(fx *consoleFixture) string { return "x@" + kmHost(fx) }, setup: func(fx *consoleFixture, to string) { fx.fk.lookupStatus[to] = 500 }, wantErr: func(err error) bool { return errors.Is(err, ErrKeymailUnreachable) }}, {name: "lookup 500 for another domain goes to the mailer", to: func(*consoleFixture) string { return "x@example.com" }, setup: func(fx *consoleFixture, to string) { fx.fk.lookupStatus[to] = 500 }, wantChannel: "email", wantMail: true}, {name: "no mailer is an error", to: func(*consoleFixture) string { return "x@example.com" }, noMailer: true, wantErr: func(err error) bool { return err != nil }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { fx, mailer := deliverFixture(t) to := c.to(fx) if c.setup != nil { c.setup(fx, to) } if c.noMailer { fx.srv.consoleMail = nil } ch, err := fx.srv.deliver(context.Background(), to, "subj", "the body", 5*time.Second) if c.wantErr != nil { if err == nil || !c.wantErr(err) { t.Fatalf("err = %v, want the case's error", err) } if ch != "" { t.Errorf("channel = %q on error", ch) } } else { if err != nil { t.Fatalf("err = %v", err) } if ch != c.wantChannel { t.Errorf("channel = %q, want %q", ch, c.wantChannel) } } if got := len(fx.fk.fedBodies()) > 0; got != c.wantFed { t.Errorf("federated = %v, want %v", got, c.wantFed) } if got := len(mailer.sentCopy()) > 0; got != c.wantMail { t.Errorf("mailed = %v, want %v", got, c.wantMail) } }) } } // The keymailShare bounds Keymail's slice of the budget: a stalled lookup for // a foreign domain leaves time for the mailer. func TestDeliverKeymailShareLeavesTimeForTheMailer(t *testing.T) { fx, mailer := deliverFixture(t) fx.fk.lookupStall["x@example.com"] = 3 * time.Second ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() start := time.Now() ch, err := fx.srv.deliver(ctx, "x@example.com", "s", "b", 200*time.Millisecond) if err != nil || ch != "email" { t.Fatalf("deliver = %q, %v; want email", ch, err) } if d := time.Since(start); d > time.Second { t.Errorf("deliver took %v", d) } if len(mailer.sentCopy()) != 1 { t.Errorf("mailer calls = %v", mailer.sentCopy()) } }