package main import ( "context" "errors" "fmt" "strings" "testing" "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" ) // failIfCalled builds a newProvisioner/newWriter seam that fails the test if // it is ever invoked — used to prove mailBackends does not make a // construction call (and therefore no network I/O) when the coordinate that // would justify it is unset. func failProvisionerIfCalled(t *testing.T) func(context.Context, string) (mail.Provisioner, error) { t.Helper() return func(ctx context.Context, region string) (mail.Provisioner, error) { t.Fatalf("newProvisioner called with region %q — mailBackends must not construct a Provisioner when Region is unset", region) return nil, nil } } func failWriterIfCalled(t *testing.T) func(context.Context) (dnsrec.Writer, error) { t.Helper() return func(ctx context.Context) (dnsrec.Writer, error) { t.Fatal("newWriter called — mailBackends must not construct a DNSWriter when PlatformSuffix is unset") return nil, nil } } // TestMailBackendsNoEnvNilBoth is property 1 from the wiring brief: with no // mail coordinates configured (the shape of every deployment before this // change, and every deployment today that has not set CARLOS_MAIL_REGION / // CARLOS_MAIL_PLATFORM_SUFFIX), both fields come back nil and NEITHER // constructor is invoked — no AWS call happens at boot. func TestMailBackendsNoEnvNilBoth(t *testing.T) { provisioner, writer := mailBackends(mail.Deployment{}, failProvisionerIfCalled(t), failWriterIfCalled(t)) if provisioner != nil { t.Errorf("provisioner = %v, want nil", provisioner) } if writer != nil { t.Errorf("writer = %v, want nil", writer) } } // TestMailBackendsProvisionerFailureDoesNotCrash is property 2: a region IS // configured, but construction fails (bad region, no AWS credentials, an // outage). Boot must not crash — mailBackends must return normally with a // nil provisioner rather than calling log.Fatal (which would os.Exit this // very test process). func TestMailBackendsProvisionerFailureDoesNotCrash(t *testing.T) { dep := mail.Deployment{Region: "eu-west-1"} failer := func(ctx context.Context, region string) (mail.Provisioner, error) { return nil, errors.New("boom: no AWS credentials on this box") } provisioner, writer := mailBackends(dep, failer, failWriterIfCalled(t)) if provisioner != nil { t.Errorf("provisioner = %v, want nil after a construction failure", provisioner) } if writer != nil { t.Errorf("writer = %v, want nil — PlatformSuffix was unset regardless of the provisioner outcome", writer) } // Reaching this line at all is part of what is under test: log.Fatal // inside mailBackends would have os.Exit'd the test binary before this // assertion ever ran. } // TestMailBackendsWriterFailureDoesNotCrash is the DNS-writer half of // property 2: a platform suffix is configured, but Route53 writer // construction fails. Boot must not crash — nil writer, no exit. func TestMailBackendsWriterFailureDoesNotCrash(t *testing.T) { dep := mail.Deployment{PlatformSuffix: "oncarlos.com"} failer := func(ctx context.Context) (dnsrec.Writer, error) { return nil, errors.New("boom: route53 unreachable") } provisioner, writer := mailBackends(dep, failProvisionerIfCalled(t), failer) if provisioner != nil { t.Errorf("provisioner = %v, want nil — Region was unset regardless of the writer outcome", provisioner) } if writer != nil { t.Errorf("writer = %v, want nil after a construction failure", writer) } } // TestMailBackendsWriterNilWithoutSuffix is property 3: a region IS // configured (so the provisioner constructs), but PlatformSuffix is not — // the writer must stay nil, and newWriter must not even be called, whether // or not the provisioner side succeeds. func TestMailBackendsWriterNilWithoutSuffix(t *testing.T) { dep := mail.Deployment{Region: "eu-west-1"} newProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) { return mail.NewMemProvisioner("123456789012"), nil } provisioner, writer := mailBackends(dep, newProvisioner, failWriterIfCalled(t)) if provisioner == nil { t.Error("provisioner = nil, want the constructed MemProvisioner — Region was set") } if writer != nil { t.Errorf("writer = %v, want nil — PlatformSuffix was unset", writer) } } // TestMailBackendsBothConfiguredBothConstructed is the positive path: both // coordinates set, both constructors succeed, both fields come back // populated with exactly what the constructor returned. func TestMailBackendsBothConfiguredBothConstructed(t *testing.T) { dep := mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com"} wantP := mail.NewMemProvisioner("123456789012") wantW := dnsrec.NewMemWriter() newProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) { if region != "eu-west-1" { t.Errorf("newProvisioner region = %q, want eu-west-1", region) } return wantP, nil } newWriter := func(ctx context.Context) (dnsrec.Writer, error) { return wantW, nil } provisioner, writer := mailBackends(dep, newProvisioner, newWriter) if provisioner != mail.Provisioner(wantP) { t.Errorf("provisioner = %v, want the constructed MemProvisioner", provisioner) } if writer != dnsrec.Writer(wantW) { t.Errorf("writer = %v, want the constructed MemWriter", writer) } } // TestConsoleMailerWiring covers consoleMailer's own coordinates (From, // Provisioner, SES client construction) — distinct from mailBackends' above, // since the console mailer additionally needs a From address and a working // SES client, neither of which mailBackends' Provisioner/Writer pair cover. func TestConsoleMailerWiring(t *testing.T) { sesOK := func(context.Context, string) (mail.EmailSender, error) { return fakeSESClient{}, nil } dep := mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test"} store := release.NewDirStore(t.TempDir()) prov := mail.NewMemProvisioner("1") t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "") if m, _ := consoleMailer(dep, store, prov, nil, sesOK, false); m == nil { t.Error("suffix set, no explicit From: want a mailer on the default address") } if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK, false); m != nil { t.Error("no suffix, no explicit From: want no mailer") } if m, _ := consoleMailer(dep, store, nil, nil, sesOK, false); m != nil { t.Error("no provisioner: want no mailer") } t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "invites@shop.example") if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK, false); m == nil { t.Error("explicit From, no suffix: want a mailer") } sesFail := func(context.Context, string) (mail.EmailSender, error) { return nil, errors.New("no creds") } if m, _ := consoleMailer(dep, store, prov, nil, sesFail, false); m != nil { t.Error("SES client construction failed: want no mailer, not a crash") } // #250: a console under claims must not have console mail until a gate // route exists — hostClaim true refuses even when every other coordinate // (suffix, region, provisioner) is set, and must do so before touching // SES at all. sesMustNotBeCalled := func(context.Context, string) (mail.EmailSender, error) { t.Fatal("newSES called — consoleMailer must not construct a SES client when hostClaim is true") return nil, nil } if m, _ := consoleMailer(dep, store, prov, nil, sesMustNotBeCalled, true); m != nil { t.Error("hostClaim true: want no mailer, even with suffix, region and provisioner set") } } type fakeSESClient struct{} func (fakeSESClient) SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { return &sesv2.SendEmailOutput{}, nil } // TestMailUserBoundary is the console's startup check on // CARLOS_MAIL_USER_BOUNDARY, the mail twin of storeBackend's // CARLOS_STORE_USER_BOUNDARY: with CARLOS_MAIL_REGION set (the mail // provisioner on), a missing or malformed boundary is refused, because the // provisioner would otherwise create sending users with no ceiling. With no // region there is no provisioner, so nothing is required. func TestMailUserBoundary(t *testing.T) { const arn = "arn:aws:iam::123456789012:policy/carlos-mail-boundary" for _, tc := range []struct { name, region, raw, want string wantErr bool }{ {name: "no region, no boundary", region: "", raw: ""}, {name: "no region, boundary ignored", region: "", raw: arn}, {name: "region, no boundary", region: "eu-west-1", raw: "", wantErr: true}, {name: "region, blank boundary", region: "eu-west-1", raw: " ", wantErr: true}, {name: "region, not a policy ARN", region: "eu-west-1", raw: "carlos-mail-boundary", wantErr: true}, {name: "region, account not 12 digits", region: "eu-west-1", raw: "arn:aws:iam::not-an-account:policy/example", wantErr: true}, {name: "region, tab inside", region: "eu-west-1", raw: "arn:aws:iam::123456789012:policy/exa\tmple", wantErr: true}, {name: "region, newline inside", region: "eu-west-1", raw: "arn:aws:iam::123456789012:policy/exa\nmple", wantErr: true}, {name: "region, AWS managed policy", region: "eu-west-1", raw: "arn:aws:iam::aws:policy/AdministratorAccess", wantErr: true}, {name: "region, boundary", region: "eu-west-1", raw: " " + arn + "\n", want: arn}, } { t.Run(tc.name, func(t *testing.T) { got, err := mailUserBoundary(tc.region, tc.raw) if tc.wantErr { if err == nil { t.Fatalf("mailUserBoundary(%q, %q) = %q, want an error", tc.region, tc.raw, got) } if !strings.Contains(err.Error(), "CARLOS_MAIL_USER_BOUNDARY") { t.Fatalf("error %q does not name CARLOS_MAIL_USER_BOUNDARY", err) } return } if err != nil { t.Fatalf("mailUserBoundary(%q, %q): %v", tc.region, tc.raw, err) } if got != tc.want { t.Fatalf("mailUserBoundary(%q, %q) = %q, want %q", tc.region, tc.raw, got, tc.want) } }) } } // fakeBoundarySweeper is a mail.Provisioner that also sweeps, answering // with a canned result, so sweepMailBoundaries' reporting can be read. type fakeBoundarySweeper struct { *mail.MemProvisioner res mail.BoundarySweep err error calls *int } func (f fakeBoundarySweeper) SweepUserBoundaries(context.Context) (mail.BoundarySweep, error) { if f.calls != nil { *f.calls++ } return f.res, f.err } // TestSweepMailBoundariesReports: the operator's last line says either // that every mail user is bounded or which are not; a failure is never // reported as success. func TestSweepMailBoundariesReports(t *testing.T) { run := func(p mail.Provisioner) []string { var lines []string sweepMailBoundaries(context.Background(), false, p, func(f string, a ...any) { lines = append(lines, fmt.Sprintf(f, a...)) }) return lines } last := func(lines []string) string { return lines[len(lines)-1] } mem := mail.NewMemProvisioner("1") ok := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 3, Bounded: 1, Fixed: []string{"carlos-mail-a-x", "carlos-mail-a-y"}}}) if len(ok) != 3 || !strings.Contains(ok[0], "carlos-mail-a-x") || last(ok) != "console: mail boundary sweep: all 3 mail users bounded (1 already, 2 fixed)" { t.Fatalf("success lines = %q", ok) } bad := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 2, Bounded: 1, Failed: []mail.BoundaryFailure{{User: "carlos-mail-a-z", Err: errors.New("AccessDenied")}}}}) if !strings.Contains(last(bad), "1 of 2 mail users NOT bounded: carlos-mail-a-z") { t.Fatalf("failure lines = %q", bad) } stopped := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 5}, err: errors.New("list failed")}) if !strings.Contains(last(stopped), "stopped after 5 mail users") || strings.Contains(strings.Join(stopped, "\n"), "all ") { t.Fatalf("stopped lines = %q", stopped) } none := run(mem) if len(none) != 1 || !strings.Contains(none[0], "skipped") { t.Fatalf("no sweeper lines = %q", none) } if run(nil)[0] != none[0] { t.Fatal("a nil provisioner must skip the same way") } } // TestSweepMailBoundariesRefusesUnderHostClaims: in claim mode the startup // sweep must not touch IAM at all (the raw provisioner bypasses the claim // mail gate and app permits), and must say why. func TestSweepMailBoundariesRefusesUnderHostClaims(t *testing.T) { calls := 0 mem := mail.NewMemProvisioner("1") p := fakeBoundarySweeper{MemProvisioner: mem, calls: &calls, res: mail.BoundarySweep{Total: 1, Fixed: []string{"carlos-mail-a-x"}}} var lines []string sweepMailBoundaries(context.Background(), true, p, func(f string, a ...any) { lines = append(lines, fmt.Sprintf(f, a...)) }) if calls != 0 { t.Fatalf("the sweep made %d backend calls under host claims, want 0", calls) } if len(lines) != 1 || !strings.Contains(lines[0], "refused while CARLOS_HOST_CLAIM is on") || !strings.Contains(lines[0], mail.ErrClaimMailPending.Error()) { t.Fatalf("lines = %q, want one refusal naming CARLOS_HOST_CLAIM", lines) } }