package console import ( "context" "errors" "net/http" "net/url" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sesv2" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/release" ) type fakeSES struct { sent []*sesv2.SendEmailInput err error } func (f *fakeSES) SendEmail(_ context.Context, in *sesv2.SendEmailInput, _ ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { if f.err != nil { return nil, f.err } f.sent = append(f.sent, in) return &sesv2.SendEmailOutput{MessageId: aws.String("m-1")}, nil } var verifiedStatus = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"} func testMailDeployment() mail.Deployment { return mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test", SNSTopicARN: "arn:aws:sns:eu-west-1:1:t"} } func TestConsoleMailFrom(t *testing.T) { cases := []struct{ explicit, suffix, want string }{ {"invites@shop.example", "oncarlos.test", "invites@shop.example"}, {"", "oncarlos.test", "noreply@console-mail.bab.oncarlos.test"}, {"", "", ""}, {" Invites@Shop.example ", "", "invites@shop.example"}, } for _, c := range cases { if got := ConsoleMailFrom(c.explicit, c.suffix); got != c.want { t.Errorf("ConsoleMailFrom(%q, %q) = %q, want %q", c.explicit, c.suffix, got, c.want) } } } func TestConsoleMailerSendsOnceVerified(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) p := mail.NewMemProvisioner("1") p.StatusOnEnsure = verifiedStatus ses := &fakeSES{} m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses) if err := m.Ensure(ctx); err != nil { t.Fatal(err) } if err := m.Send(ctx, "bill@gmail.com", "subj", "body"); err != nil { t.Fatal(err) } if len(ses.sent) != 1 { t.Fatalf("sent %d, want 1", len(ses.sent)) } in := ses.sent[0] if aws.ToString(in.FromEmailAddress) != "noreply@console-mail.bab.oncarlos.test" || in.Destination.ToAddresses[0] != "bill@gmail.com" || aws.ToString(in.TenantName) != mail.TenantName(release.OpsAccount) || aws.ToString(in.ConfigurationSetName) != mail.ConfigSetName(release.OpsAccount) { t.Fatalf("SendEmailInput = %+v", in) } } // Review Focus 4. func TestConsoleMailerRefusesUntilVerified(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) p := mail.NewMemProvisioner("1") // identities stay PENDING ses := &fakeSES{} m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses) if err := m.Send(ctx, "bill@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) { t.Fatalf("Send before verification = %v, want ErrConsoleMailNotReady", err) } if len(ses.sent) != 0 { t.Fatal("sent through an unverified identity") } } func TestConsoleMailerReady(t *testing.T) { ctx := context.Background() deployment := testMailDeployment() from := "noreply@console-mail.bab.oncarlos.test" // Unverified: false, and nothing goes out through SES. p := mail.NewMemProvisioner("1") // identities stay PENDING ses := &fakeSES{} m := newSESConsoleMailer(from, release.NewDirStore(t.TempDir()), p, dnsrec.NewMemWriter(), deployment, ses) if m.Ready(ctx) { t.Fatal("Ready = true for an unverified identity") } if len(ses.sent) != 0 { t.Fatal("Ready sent mail") } // Verified: true, and still nothing sent. p = mail.NewMemProvisioner("1") p.StatusOnEnsure = verifiedStatus ses = &fakeSES{} m = newSESConsoleMailer(from, release.NewDirStore(t.TempDir()), p, dnsrec.NewMemWriter(), deployment, ses) if !m.Ready(ctx) { t.Fatal("Ready = false for a verified identity") } if len(ses.sent) != 0 { t.Fatal("Ready sent mail") } // A context that has already ended: false, even though the identity is // verified and cached. ended, cancel := context.WithCancel(ctx) cancel() for i := 0; i < 20; i++ { if m.Ready(ended) { t.Fatal("Ready = true on an ended context") } } } func TestServerConsoleName(t *testing.T) { for _, c := range []struct{ set, want string }{ {"", "the CARLOS platform"}, {" ", "the CARLOS platform"}, {" Acme Console ", "Acme Console"}, } { s := &Server{consoleNameCfg: c.set} if got := s.consoleName(); got != c.want { t.Errorf("consoleName() with %q = %q, want %q", c.set, got, c.want) } } } func TestConsoleMailerCachesAVerifiedIdentity(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) p := mail.NewMemProvisioner("1") p.StatusOnEnsure = verifiedStatus ses := &fakeSES{} m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses) if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil { t.Fatal(err) } p.FailNext["IdentityStatus"] = errors.New("SES is down") if err := m.Send(ctx, "b@gmail.com", "s", "b"); err != nil { t.Fatalf("second send re-checked SES: %v", err) } } func TestConsoleMailerRechecksPendingAfterFiveMinutes(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) p := mail.NewMemProvisioner("1") ses := &fakeSES{} m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses) now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) m.now = func() time.Time { return now } if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) { t.Fatalf("first = %v", err) } for k := range p.Identities { p.Identities[k].Status = verifiedStatus } if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) { t.Fatalf("within 5 minutes = %v, want the cached not-ready", err) } now = now.Add(5*time.Minute + time.Second) if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil { t.Fatalf("after 5 minutes = %v, want sent", err) } } // TestConsoleMailerRecoversFromALapsedDomainRecord covers the ruling added // alongside mail #216: ready() must clear m.ensured when VerifyDomain errors // (the domain record, or its claim, is gone — e.g. an unverified claim // lapsed, mail.ClaimLapse), so the NEXT recheck re-runs EnsureDomain instead // of failing forever on a stale "ensured" latch. func TestConsoleMailerRecoversFromALapsedDomainRecord(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) p := mail.NewMemProvisioner("1") // identities stay PENDING until driven verified below ses := &fakeSES{} dep := testMailDeployment() m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), dep, ses) now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) m.now = func() time.Time { return now } // A successful Ensure writes the domain record and latches m.ensured, // same as the console's startup call would. if err := m.Ensure(ctx); err != nil { t.Fatalf("ensure: %v", err) } key := mail.DomainKey(release.OpsAccount, m.domain) if _, _, err := s.Get(ctx, key); err != nil { t.Fatalf("domain record missing before deletion: %v", err) } // The record (or its claim) is gone — the shape an unverified claim's // lapse (mail.ClaimLapse) leaves behind. m.ensured is still true, so // without the ruling's fix ready() would call ensureLocked, find // m.ensured latched, skip EnsureDomain entirely, and fail this way // FOREVER — no recheck would ever repair it. if err := s.Delete(ctx, key); err != nil { t.Fatalf("delete domain record: %v", err) } now = now.Add(consoleMailPendingRecheck + time.Second) if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) { t.Fatalf("send after lapse = %v, want ErrConsoleMailNotReady", err) } // Drive the (still-extant, provisioner-side) identity to verified, the // same seam TestConsoleMailerRechecksPendingAfterFiveMinutes uses. for k := range p.Identities { p.Identities[k].Status = verifiedStatus } now = now.Add(consoleMailPendingRecheck + time.Second) if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil { t.Fatalf("send after recheck re-ran EnsureDomain: %v", err) } if _, _, err := s.Get(ctx, key); err != nil { t.Fatalf("domain record should exist again after re-ensure: %v", err) } } // Review Focus 5. func TestConsoleMailAppIsReservedInOps(t *testing.T) { fx := newConsoleFixture(t) ops, err := fx.st.AccountByID(OpsAccountID) if err != nil { t.Fatal(err) } if got, _, _ := fx.srv.claimApp(context.Background(), ops, consoleMailApp, "paul@keymail.dev", "", true, regionReq{}); got != claimReserved { t.Fatalf("claimApp(bab, %q) = %v, want claimReserved", consoleMailApp, got) } } // Final review Important #1: rename was the other door onto the reserved // name. Refused in the ops account, the same words as "new". func TestRenameOntoConsoleMailIsRefusedInOps(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "sprout") c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"), url.Values{"csrf": {csrf}, "to": {consoleMailApp}, "confirmed": {"1"}}) if status != http.StatusUnprocessableEntity || !strings.Contains(body, "That name is reserved.") { t.Fatalf("rename onto %q = %d, want 422 with the reserved refusal:\n%s", consoleMailApp, status, body) } if has, err := fx.st.HasAppIn(OpsAccountID, consoleMailApp); err != nil || has { t.Fatalf("HasAppIn(%s) = %v %v, want no rename", consoleMailApp, has, err) } if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has { t.Fatalf("HasAppIn(sprout) = %v %v, want the app untouched", has, err) } } func TestReservedAppName(t *testing.T) { ops := &Account{Sqid: release.OpsAccount} other := &Account{Sqid: "xyz"} for _, tc := range []struct { acct *Account name string want bool }{ {ops, "new", true}, {other, "new", true}, {ops, consoleMailApp, true}, {other, consoleMailApp, false}, {ops, "blog", false}, } { if got := reservedAppName(tc.acct, tc.name); got != tc.want { t.Errorf("reservedAppName(%s, %q) = %v, want %v", tc.acct.Sqid, tc.name, got, tc.want) } } } // stallingProvisioner never answers EnsureTenant until released, whatever // the context says: the AWS SDK call that hangs. type stallingProvisioner struct { *mail.MemProvisioner entered chan struct{} release chan struct{} } func (p *stallingProvisioner) EnsureTenant(context.Context, string) error { close(p.entered) <-p.release return nil } // Final review Important #3: an ensure stalled inside the provider holds the // mailer; a Send must still give up at its own deadline, not the ensure's. func TestConsoleMailerSendHonoursItsDeadlineWhileEnsureHolds(t *testing.T) { p := &stallingProvisioner{MemProvisioner: mail.NewMemProvisioner("1"), entered: make(chan struct{}), release: make(chan struct{})} m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", release.NewDirStore(t.TempDir()), p, dnsrec.NewMemWriter(), testMailDeployment(), &fakeSES{}) ensured := make(chan struct{}) go func() { defer close(ensured); m.Ensure(context.Background()) }() // Let the ensure finish before TempDir's cleanup removes its store. defer func() { close(p.release); <-ensured }() <-p.entered ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() done := make(chan error, 1) go func() { done <- m.Send(ctx, "bill@gmail.com", "s", "b") }() select { case err := <-done: if !errors.Is(err, ErrConsoleMailNotReady) || !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("Send while the ensure holds = %v, want ErrConsoleMailNotReady wrapping DeadlineExceeded", err) } case <-time.After(time.Second): t.Fatal("Send blocked behind a stalled ensure past its own deadline") } }