| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/aws/aws-sdk-go-v2/service/sesv2" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/dnsrec" |
| 13 | "github.com/carlosframework/platform/internal/mail" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | // failIfCalled builds a newProvisioner/newWriter seam that fails the test if |
| 18 | // it is ever invoked — used to prove mailBackends does not make a |
| 19 | // construction call (and therefore no network I/O) when the coordinate that |
| 20 | // would justify it is unset. |
| 21 | |
| 22 | func failProvisionerIfCalled(t *testing.T) func(context.Context, string) (mail.Provisioner, error) { |
| 23 | t.Helper() |
| 24 | return func(ctx context.Context, region string) (mail.Provisioner, error) { |
| 25 | t.Fatalf("newProvisioner called with region %q — mailBackends must not construct a Provisioner when Region is unset", region) |
| 26 | return nil, nil |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func failWriterIfCalled(t *testing.T) func(context.Context) (dnsrec.Writer, error) { |
| 31 | t.Helper() |
| 32 | return func(ctx context.Context) (dnsrec.Writer, error) { |
| 33 | t.Fatal("newWriter called — mailBackends must not construct a DNSWriter when PlatformSuffix is unset") |
| 34 | return nil, nil |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // TestMailBackendsNoEnvNilBoth is property 1 from the wiring brief: with no |
| 39 | // mail coordinates configured (the shape of every deployment before this |
| 40 | // change, and every deployment today that has not set CARLOS_MAIL_REGION / |
| 41 | // CARLOS_MAIL_PLATFORM_SUFFIX), both fields come back nil and NEITHER |
| 42 | // constructor is invoked — no AWS call happens at boot. |
| 43 | func TestMailBackendsNoEnvNilBoth(t *testing.T) { |
| 44 | provisioner, writer := mailBackends(mail.Deployment{}, failProvisionerIfCalled(t), failWriterIfCalled(t)) |
| 45 | if provisioner != nil { |
| 46 | t.Errorf("provisioner = %v, want nil", provisioner) |
| 47 | } |
| 48 | if writer != nil { |
| 49 | t.Errorf("writer = %v, want nil", writer) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // TestMailBackendsProvisionerFailureDoesNotCrash is property 2: a region IS |
| 54 | // configured, but construction fails (bad region, no AWS credentials, an |
| 55 | // outage). Boot must not crash — mailBackends must return normally with a |
| 56 | // nil provisioner rather than calling log.Fatal (which would os.Exit this |
| 57 | // very test process). |
| 58 | func TestMailBackendsProvisionerFailureDoesNotCrash(t *testing.T) { |
| 59 | dep := mail.Deployment{Region: "eu-west-1"} |
| 60 | failer := func(ctx context.Context, region string) (mail.Provisioner, error) { |
| 61 | return nil, errors.New("boom: no AWS credentials on this box") |
| 62 | } |
| 63 | provisioner, writer := mailBackends(dep, failer, failWriterIfCalled(t)) |
| 64 | if provisioner != nil { |
| 65 | t.Errorf("provisioner = %v, want nil after a construction failure", provisioner) |
| 66 | } |
| 67 | if writer != nil { |
| 68 | t.Errorf("writer = %v, want nil — PlatformSuffix was unset regardless of the provisioner outcome", writer) |
| 69 | } |
| 70 | // Reaching this line at all is part of what is under test: log.Fatal |
| 71 | // inside mailBackends would have os.Exit'd the test binary before this |
| 72 | // assertion ever ran. |
| 73 | } |
| 74 | |
| 75 | // TestMailBackendsWriterFailureDoesNotCrash is the DNS-writer half of |
| 76 | // property 2: a platform suffix is configured, but Route53 writer |
| 77 | // construction fails. Boot must not crash — nil writer, no exit. |
| 78 | func TestMailBackendsWriterFailureDoesNotCrash(t *testing.T) { |
| 79 | dep := mail.Deployment{PlatformSuffix: "oncarlos.com"} |
| 80 | failer := func(ctx context.Context) (dnsrec.Writer, error) { |
| 81 | return nil, errors.New("boom: route53 unreachable") |
| 82 | } |
| 83 | provisioner, writer := mailBackends(dep, failProvisionerIfCalled(t), failer) |
| 84 | if provisioner != nil { |
| 85 | t.Errorf("provisioner = %v, want nil — Region was unset regardless of the writer outcome", provisioner) |
| 86 | } |
| 87 | if writer != nil { |
| 88 | t.Errorf("writer = %v, want nil after a construction failure", writer) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // TestMailBackendsWriterNilWithoutSuffix is property 3: a region IS |
| 93 | // configured (so the provisioner constructs), but PlatformSuffix is not — |
| 94 | // the writer must stay nil, and newWriter must not even be called, whether |
| 95 | // or not the provisioner side succeeds. |
| 96 | func TestMailBackendsWriterNilWithoutSuffix(t *testing.T) { |
| 97 | dep := mail.Deployment{Region: "eu-west-1"} |
| 98 | newProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) { |
| 99 | return mail.NewMemProvisioner("123456789012"), nil |
| 100 | } |
| 101 | provisioner, writer := mailBackends(dep, newProvisioner, failWriterIfCalled(t)) |
| 102 | if provisioner == nil { |
| 103 | t.Error("provisioner = nil, want the constructed MemProvisioner — Region was set") |
| 104 | } |
| 105 | if writer != nil { |
| 106 | t.Errorf("writer = %v, want nil — PlatformSuffix was unset", writer) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // TestMailBackendsBothConfiguredBothConstructed is the positive path: both |
| 111 | // coordinates set, both constructors succeed, both fields come back |
| 112 | // populated with exactly what the constructor returned. |
| 113 | func TestMailBackendsBothConfiguredBothConstructed(t *testing.T) { |
| 114 | dep := mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com"} |
| 115 | wantP := mail.NewMemProvisioner("123456789012") |
| 116 | wantW := dnsrec.NewMemWriter() |
| 117 | newProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) { |
| 118 | if region != "eu-west-1" { |
| 119 | t.Errorf("newProvisioner region = %q, want eu-west-1", region) |
| 120 | } |
| 121 | return wantP, nil |
| 122 | } |
| 123 | newWriter := func(ctx context.Context) (dnsrec.Writer, error) { |
| 124 | return wantW, nil |
| 125 | } |
| 126 | provisioner, writer := mailBackends(dep, newProvisioner, newWriter) |
| 127 | if provisioner != mail.Provisioner(wantP) { |
| 128 | t.Errorf("provisioner = %v, want the constructed MemProvisioner", provisioner) |
| 129 | } |
| 130 | if writer != dnsrec.Writer(wantW) { |
| 131 | t.Errorf("writer = %v, want the constructed MemWriter", writer) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // TestConsoleMailerWiring covers consoleMailer's own coordinates (From, |
| 136 | // Provisioner, SES client construction) — distinct from mailBackends' above, |
| 137 | // since the console mailer additionally needs a From address and a working |
| 138 | // SES client, neither of which mailBackends' Provisioner/Writer pair cover. |
| 139 | func TestConsoleMailerWiring(t *testing.T) { |
| 140 | sesOK := func(context.Context, string) (mail.EmailSender, error) { return fakeSESClient{}, nil } |
| 141 | dep := mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test"} |
| 142 | store := release.NewDirStore(t.TempDir()) |
| 143 | prov := mail.NewMemProvisioner("1") |
| 144 | |
| 145 | t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "") |
| 146 | if m, _ := consoleMailer(dep, store, prov, nil, sesOK, false); m == nil { |
| 147 | t.Error("suffix set, no explicit From: want a mailer on the default address") |
| 148 | } |
| 149 | if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK, false); m != nil { |
| 150 | t.Error("no suffix, no explicit From: want no mailer") |
| 151 | } |
| 152 | if m, _ := consoleMailer(dep, store, nil, nil, sesOK, false); m != nil { |
| 153 | t.Error("no provisioner: want no mailer") |
| 154 | } |
| 155 | t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "invites@shop.example") |
| 156 | if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK, false); m == nil { |
| 157 | t.Error("explicit From, no suffix: want a mailer") |
| 158 | } |
| 159 | sesFail := func(context.Context, string) (mail.EmailSender, error) { return nil, errors.New("no creds") } |
| 160 | if m, _ := consoleMailer(dep, store, prov, nil, sesFail, false); m != nil { |
| 161 | t.Error("SES client construction failed: want no mailer, not a crash") |
| 162 | } |
| 163 | // #250: a console under claims must not have console mail until a gate |
| 164 | // route exists — hostClaim true refuses even when every other coordinate |
| 165 | // (suffix, region, provisioner) is set, and must do so before touching |
| 166 | // SES at all. |
| 167 | sesMustNotBeCalled := func(context.Context, string) (mail.EmailSender, error) { |
| 168 | t.Fatal("newSES called — consoleMailer must not construct a SES client when hostClaim is true") |
| 169 | return nil, nil |
| 170 | } |
| 171 | if m, _ := consoleMailer(dep, store, prov, nil, sesMustNotBeCalled, true); m != nil { |
| 172 | t.Error("hostClaim true: want no mailer, even with suffix, region and provisioner set") |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | type fakeSESClient struct{} |
| 177 | |
| 178 | func (fakeSESClient) SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { |
| 179 | return &sesv2.SendEmailOutput{}, nil |
| 180 | } |
| 181 | |
| 182 | // TestMailUserBoundary is the console's startup check on |
| 183 | // CARLOS_MAIL_USER_BOUNDARY, the mail twin of storeBackend's |
| 184 | // CARLOS_STORE_USER_BOUNDARY: with CARLOS_MAIL_REGION set (the mail |
| 185 | // provisioner on), a missing or malformed boundary is refused, because the |
| 186 | // provisioner would otherwise create sending users with no ceiling. With no |
| 187 | // region there is no provisioner, so nothing is required. |
| 188 | func TestMailUserBoundary(t *testing.T) { |
| 189 | const arn = "arn:aws:iam::123456789012:policy/carlos-mail-boundary" |
| 190 | for _, tc := range []struct { |
| 191 | name, region, raw, want string |
| 192 | wantErr bool |
| 193 | }{ |
| 194 | {name: "no region, no boundary", region: "", raw: ""}, |
| 195 | {name: "no region, boundary ignored", region: "", raw: arn}, |
| 196 | {name: "region, no boundary", region: "eu-west-1", raw: "", wantErr: true}, |
| 197 | {name: "region, blank boundary", region: "eu-west-1", raw: " ", wantErr: true}, |
| 198 | {name: "region, not a policy ARN", region: "eu-west-1", raw: "carlos-mail-boundary", wantErr: true}, |
| 199 | {name: "region, account not 12 digits", region: "eu-west-1", raw: "arn:aws:iam::not-an-account:policy/example", wantErr: true}, |
| 200 | {name: "region, tab inside", region: "eu-west-1", raw: "arn:aws:iam::123456789012:policy/exa\tmple", wantErr: true}, |
| 201 | {name: "region, newline inside", region: "eu-west-1", raw: "arn:aws:iam::123456789012:policy/exa\nmple", wantErr: true}, |
| 202 | {name: "region, AWS managed policy", region: "eu-west-1", raw: "arn:aws:iam::aws:policy/AdministratorAccess", wantErr: true}, |
| 203 | {name: "region, boundary", region: "eu-west-1", raw: " " + arn + "\n", want: arn}, |
| 204 | } { |
| 205 | t.Run(tc.name, func(t *testing.T) { |
| 206 | got, err := mailUserBoundary(tc.region, tc.raw) |
| 207 | if tc.wantErr { |
| 208 | if err == nil { |
| 209 | t.Fatalf("mailUserBoundary(%q, %q) = %q, want an error", tc.region, tc.raw, got) |
| 210 | } |
| 211 | if !strings.Contains(err.Error(), "CARLOS_MAIL_USER_BOUNDARY") { |
| 212 | t.Fatalf("error %q does not name CARLOS_MAIL_USER_BOUNDARY", err) |
| 213 | } |
| 214 | return |
| 215 | } |
| 216 | if err != nil { |
| 217 | t.Fatalf("mailUserBoundary(%q, %q): %v", tc.region, tc.raw, err) |
| 218 | } |
| 219 | if got != tc.want { |
| 220 | t.Fatalf("mailUserBoundary(%q, %q) = %q, want %q", tc.region, tc.raw, got, tc.want) |
| 221 | } |
| 222 | }) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // fakeBoundarySweeper is a mail.Provisioner that also sweeps, answering |
| 227 | // with a canned result, so sweepMailBoundaries' reporting can be read. |
| 228 | type fakeBoundarySweeper struct { |
| 229 | *mail.MemProvisioner |
| 230 | res mail.BoundarySweep |
| 231 | err error |
| 232 | calls *int |
| 233 | } |
| 234 | |
| 235 | func (f fakeBoundarySweeper) SweepUserBoundaries(context.Context) (mail.BoundarySweep, error) { |
| 236 | if f.calls != nil { |
| 237 | *f.calls++ |
| 238 | } |
| 239 | return f.res, f.err |
| 240 | } |
| 241 | |
| 242 | // TestSweepMailBoundariesReports: the operator's last line says either |
| 243 | // that every mail user is bounded or which are not; a failure is never |
| 244 | // reported as success. |
| 245 | func TestSweepMailBoundariesReports(t *testing.T) { |
| 246 | run := func(p mail.Provisioner) []string { |
| 247 | var lines []string |
| 248 | sweepMailBoundaries(context.Background(), false, p, func(f string, a ...any) { lines = append(lines, fmt.Sprintf(f, a...)) }) |
| 249 | return lines |
| 250 | } |
| 251 | last := func(lines []string) string { return lines[len(lines)-1] } |
| 252 | mem := mail.NewMemProvisioner("1") |
| 253 | |
| 254 | ok := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 3, Bounded: 1, Fixed: []string{"carlos-mail-a-x", "carlos-mail-a-y"}}}) |
| 255 | 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)" { |
| 256 | t.Fatalf("success lines = %q", ok) |
| 257 | } |
| 258 | bad := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 2, Bounded: 1, |
| 259 | Failed: []mail.BoundaryFailure{{User: "carlos-mail-a-z", Err: errors.New("AccessDenied")}}}}) |
| 260 | if !strings.Contains(last(bad), "1 of 2 mail users NOT bounded: carlos-mail-a-z") { |
| 261 | t.Fatalf("failure lines = %q", bad) |
| 262 | } |
| 263 | stopped := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 5}, err: errors.New("list failed")}) |
| 264 | if !strings.Contains(last(stopped), "stopped after 5 mail users") || strings.Contains(strings.Join(stopped, "\n"), "all ") { |
| 265 | t.Fatalf("stopped lines = %q", stopped) |
| 266 | } |
| 267 | none := run(mem) |
| 268 | if len(none) != 1 || !strings.Contains(none[0], "skipped") { |
| 269 | t.Fatalf("no sweeper lines = %q", none) |
| 270 | } |
| 271 | if run(nil)[0] != none[0] { |
| 272 | t.Fatal("a nil provisioner must skip the same way") |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | // TestSweepMailBoundariesRefusesUnderHostClaims: in claim mode the startup |
| 277 | // sweep must not touch IAM at all (the raw provisioner bypasses the claim |
| 278 | // mail gate and app permits), and must say why. |
| 279 | func TestSweepMailBoundariesRefusesUnderHostClaims(t *testing.T) { |
| 280 | calls := 0 |
| 281 | mem := mail.NewMemProvisioner("1") |
| 282 | p := fakeBoundarySweeper{MemProvisioner: mem, calls: &calls, res: mail.BoundarySweep{Total: 1, Fixed: []string{"carlos-mail-a-x"}}} |
| 283 | var lines []string |
| 284 | sweepMailBoundaries(context.Background(), true, p, func(f string, a ...any) { lines = append(lines, fmt.Sprintf(f, a...)) }) |
| 285 | if calls != 0 { |
| 286 | t.Fatalf("the sweep made %d backend calls under host claims, want 0", calls) |
| 287 | } |
| 288 | if len(lines) != 1 || !strings.Contains(lines[0], "refused while CARLOS_HOST_CLAIM is on") || !strings.Contains(lines[0], mail.ErrClaimMailPending.Error()) { |
| 289 | t.Fatalf("lines = %q, want one refusal naming CARLOS_HOST_CLAIM", lines) |
| 290 | } |
| 291 | } |
| 292 | |