| 1 | package console |
| 2 | |
| 3 | // The member half of the email API (apimail.go, tenant-email task 17). What |
| 4 | // is pinned here, in the order it would hurt if lost: a stranger probing any |
| 5 | // of the ten doors learns nothing (the byte-identical 404 the whole |
| 6 | // /api/cli surface keeps, and — new to this file — the same 404 when this |
| 7 | // deployment has no SES estate at all); the standalone-credential reply is |
| 8 | // the only one that ever carries a password, and it is uncacheable; a |
| 9 | // re-declare that changes nothing writes no audit line and bumps no serial; |
| 10 | // and the enable door always leaves a grant behind BEFORE it provisions |
| 11 | // anything, so the limiter never runs an app on the wrong cap. |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "encoding/json" |
| 16 | "net/http" |
| 17 | "net/url" |
| 18 | "strings" |
| 19 | "testing" |
| 20 | "time" |
| 21 | |
| 22 | "github.com/carlosframework/platform/internal/appconfig" |
| 23 | "github.com/carlosframework/platform/internal/dnsrec" |
| 24 | "github.com/carlosframework/platform/internal/mail" |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | ) |
| 27 | |
| 28 | func emailFixture(t *testing.T, code string) (*consoleFixture, string) { |
| 29 | t.Helper() |
| 30 | fx := newConsoleFixture(t) |
| 31 | now := time.Now() |
| 32 | if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| 39 | } |
| 40 | |
| 41 | func emailURL(fx *consoleFixture, app string) string { |
| 42 | return fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/" + app + "/email" |
| 43 | } |
| 44 | |
| 45 | // emailArmed is emailFixture with a whole deployment behind it: an |
| 46 | // in-memory SES/IAM backend, an in-memory DNS writer, a secrets key the |
| 47 | // console can seal with, a platform suffix, and an operator allowlist. It |
| 48 | // hands back the fake provisioner and DNS writer so a test can assert on |
| 49 | // the estate they actually built. |
| 50 | func emailArmed(t *testing.T, code string) (*consoleFixture, *mail.MemProvisioner, *dnsrec.MemWriter, string) { |
| 51 | t.Helper() |
| 52 | fx, token := emailFixture(t, code) |
| 53 | pub, _, _ := sealedFor(t, "irrelevant") |
| 54 | fake := mail.NewMemProvisioner("1") |
| 55 | writer := dnsrec.NewMemWriter() |
| 56 | fx.srv.cfg.SecretsPub = pub |
| 57 | fx.srv.cfg.MailProvisioner = fake |
| 58 | fx.srv.cfg.DNSWriter = writer |
| 59 | fx.srv.cfg.MailDeployment = mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com"} |
| 60 | // Spelled with different capitals on purpose, matching storeArmed: an |
| 61 | // operator list is typed by a human into a unit file, and addresses are |
| 62 | // case-insensitive. |
| 63 | fx.srv.cfg.MailOperators = []string{"Ops@Keymail.dev"} |
| 64 | return fx, fake, writer, token |
| 65 | } |
| 66 | |
| 67 | // emailOperatorToken mints a token for a genuine fleet operator (the |
| 68 | // store.IsOperator bit set) on MailOperators — and DELIBERATELY does NOT |
| 69 | // add them as a member of fx's account. I3: the pause door must be |
| 70 | // drivable by an operator who is not a member of the account they are |
| 71 | // pausing, which is the exact scenario the abuse brake exists for, so the |
| 72 | // standard operator fixture proves that by construction rather than by a |
| 73 | // separate test remembering to skip AddMember. |
| 74 | func emailOperatorToken(t *testing.T, fx *consoleFixture, code string) string { |
| 75 | t.Helper() |
| 76 | if err := fx.st.SetOperator("ops@keymail.dev", true); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | return cliToken(t, fx, "ops@keymail.dev", code) |
| 80 | } |
| 81 | |
| 82 | func declareEmail(t *testing.T, fx *consoleFixture, token, app, from string) { |
| 83 | t.Helper() |
| 84 | status, out := postJSON(t, emailURL(fx, app), map[string]any{"from": from}, token) |
| 85 | if status != http.StatusOK { |
| 86 | t.Fatalf("declare %s = %d %v", app, status, out) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func ensureEmailDomain(t *testing.T, fx *consoleFixture, token, app, domain string) map[string]any { |
| 91 | t.Helper() |
| 92 | status, out := postJSON(t, emailURL(fx, app)+"/domains", map[string]any{"domain": domain}, token) |
| 93 | if status != http.StatusOK { |
| 94 | t.Fatalf("ensure domain %s = %d %v", domain, status, out) |
| 95 | } |
| 96 | return out |
| 97 | } |
| 98 | |
| 99 | func verifyEmailDomain(t *testing.T, fx *consoleFixture, token, app, domain string) { |
| 100 | t.Helper() |
| 101 | status, out := postJSON(t, emailURL(fx, app)+"/domains/"+domain+"/verify", map[string]any{}, token) |
| 102 | if status != http.StatusOK { |
| 103 | t.Fatalf("verify domain %s = %d %v", domain, status, out) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func enableEmail(t *testing.T, fx *consoleFixture, token, app string) map[string]any { |
| 108 | t.Helper() |
| 109 | status, out := postJSON(t, emailURL(fx, app)+"/enable", map[string]any{}, token) |
| 110 | if status != http.StatusOK { |
| 111 | t.Fatalf("enable %s = %d %v", app, status, out) |
| 112 | } |
| 113 | return out |
| 114 | } |
| 115 | |
| 116 | // provisionEmail runs the whole self-serve happy path — declare, ensure a |
| 117 | // platform-suffixed domain, drive the fake to verified, verify, enable — |
| 118 | // leaving app with a live, delivered credential. |
| 119 | func provisionEmail(t *testing.T, fx *consoleFixture, fake *mail.MemProvisioner, token, app, domain, from string) { |
| 120 | t.Helper() |
| 121 | declareEmail(t, fx, token, app, from) |
| 122 | fake.StatusOnEnsure = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"} |
| 123 | ensureEmailDomain(t, fx, token, app, domain) |
| 124 | verifyEmailDomain(t, fx, token, app, domain) |
| 125 | enableEmail(t, fx, token, app) |
| 126 | } |
| 127 | |
| 128 | // pendingList reads the "pending" field of a domain-ensure reply — a list |
| 129 | // of record OBJECTS (name/type/values/ttl), not plain strings, so it needs |
| 130 | // its own reader rather than strList. |
| 131 | func pendingList(t *testing.T, out map[string]any) []any { |
| 132 | t.Helper() |
| 133 | raw, ok := out["pending"].([]any) |
| 134 | if !ok { |
| 135 | t.Fatalf("pending = %#v, want a list", out["pending"]) |
| 136 | } |
| 137 | return raw |
| 138 | } |
| 139 | |
| 140 | // emailRecord reads the record straight out of the bucket — the check that |
| 141 | // a 200 actually landed, and that a refusal left nothing behind. |
| 142 | func emailRecord(t *testing.T, fx *consoleFixture, app string) *mail.Record { |
| 143 | t.Helper() |
| 144 | rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, app) |
| 145 | if err != nil { |
| 146 | t.Fatalf("read email record: %v", err) |
| 147 | } |
| 148 | return rec |
| 149 | } |
| 150 | |
| 151 | // ---- the gate --------------------------------------------------------------- |
| 152 | |
| 153 | // TestEmailDoorsAre404ForNonMembers: on all ten doors, a token that belongs |
| 154 | // to no account, an account segment that doesn't exist, and an app the |
| 155 | // account never claimed are one and the same 404 — byte-identical to |
| 156 | // http.NotFound's own output. This runs on an UNARMED fixture (no |
| 157 | // MailProvisioner, no secrets key) on purpose: the member gate must fire |
| 158 | // before any of that is even consulted. |
| 159 | func TestEmailDoorsAre404ForNonMembers(t *testing.T) { |
| 160 | fx, token := emailFixture(t, "code-email-gate") |
| 161 | eve := cliToken(t, fx, "eve@keymail.dev", "code-email-gate-eve") |
| 162 | |
| 163 | doors := []struct { |
| 164 | name string |
| 165 | call func(base, bearer string) (int, string) |
| 166 | }{ |
| 167 | {"GET status", func(base, bearer string) (int, string) { return getRaw(t, base, bearer) }}, |
| 168 | {"GET events", func(base, bearer string) (int, string) { return getRaw(t, base+"/events", bearer) }}, |
| 169 | {"GET events push", func(base, bearer string) (int, string) { return getRaw(t, base+"/events/push", bearer) }}, |
| 170 | {"PUT events push", func(base, bearer string) (int, string) { |
| 171 | return putRaw(t, base+"/events/push", map[string]any{"path": "/mail/events"}, bearer) |
| 172 | }}, |
| 173 | {"DELETE events push", func(base, bearer string) (int, string) { |
| 174 | status, _, body := rawReq(t, http.MethodDelete, base+"/events/push", bearer, "") |
| 175 | return status, body |
| 176 | }}, |
| 177 | {"POST declare", func(base, bearer string) (int, string) { |
| 178 | return postRaw(t, base, map[string]any{"from": "hello@example.com"}, bearer) |
| 179 | }}, |
| 180 | {"POST domains", func(base, bearer string) (int, string) { |
| 181 | return postRaw(t, base+"/domains", map[string]any{"domain": "example.com"}, bearer) |
| 182 | }}, |
| 183 | {"POST domains verify", func(base, bearer string) (int, string) { |
| 184 | return postRaw(t, base+"/domains/example.com/verify", map[string]any{}, bearer) |
| 185 | }}, |
| 186 | {"DELETE domains", func(base, bearer string) (int, string) { |
| 187 | status, _, body := rawReq(t, http.MethodDelete, base+"/domains/example.com", bearer, "") |
| 188 | return status, body |
| 189 | }}, |
| 190 | {"POST enable", func(base, bearer string) (int, string) { |
| 191 | return postRaw(t, base+"/enable", map[string]any{}, bearer) |
| 192 | }}, |
| 193 | {"POST rotate", func(base, bearer string) (int, string) { |
| 194 | return postRaw(t, base+"/rotate", map[string]any{}, bearer) |
| 195 | }}, |
| 196 | {"POST credentials", func(base, bearer string) (int, string) { |
| 197 | return postRaw(t, base+"/credentials", map[string]any{"name": "laptop"}, bearer) |
| 198 | }}, |
| 199 | {"DELETE credentials", func(base, bearer string) (int, string) { |
| 200 | status, _, body := rawReq(t, http.MethodDelete, base+"/credentials/laptop", bearer, "") |
| 201 | return status, body |
| 202 | }}, |
| 203 | {"POST pause", func(base, bearer string) (int, string) { |
| 204 | return postRaw(t, base+"/pause", map[string]any{"paused": true}, bearer) |
| 205 | }}, |
| 206 | } |
| 207 | for _, d := range doors { |
| 208 | t.Run(d.name, func(t *testing.T) { |
| 209 | // (a) a member of nothing, against a real account and a real app. |
| 210 | statusA, bodyA := d.call(emailURL(fx, "titogo"), eve) |
| 211 | // (b) a member, against an account that does not exist. |
| 212 | statusB, bodyB := d.call(fx.ts.URL+"/api/cli/apps/zzzz/titogo/email", token) |
| 213 | // (c) a member of the account, against an app it never claimed. |
| 214 | statusC, bodyC := d.call(emailURL(fx, "ghost-app"), token) |
| 215 | if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| 216 | t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", d.name, statusA, statusB, statusC) |
| 217 | } |
| 218 | if bodyA != bodyB || bodyB != bodyC { |
| 219 | t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", d.name, bodyA, bodyB, bodyC) |
| 220 | } |
| 221 | if bodyA != "404 page not found\n" { |
| 222 | t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", d.name, bodyA) |
| 223 | } |
| 224 | }) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // TestEmailDoorsAre404WithNoProvisioner: a deployment with no SES estate |
| 229 | // answers 404 on every door, never a panic on the nil interface and never |
| 230 | // the 503 store's sibling doors give for a missing backend — see |
| 231 | // apimail.go's file comment for why the two differ. |
| 232 | func TestEmailDoorsAre404WithNoProvisioner(t *testing.T) { |
| 233 | fx, _, _, token := emailArmed(t, "code-email-noprovisioner") |
| 234 | opsToken := emailOperatorToken(t, fx, "code-email-noprovisioner-ops") |
| 235 | fx.srv.cfg.MailProvisioner = nil |
| 236 | |
| 237 | calls := []struct { |
| 238 | name string |
| 239 | do func() (int, string) |
| 240 | }{ |
| 241 | {"GET status", func() (int, string) { return getRaw(t, emailURL(fx, "titogo"), token) }}, |
| 242 | {"GET events", func() (int, string) { return getRaw(t, emailURL(fx, "titogo")+"/events", token) }}, |
| 243 | {"GET events push", func() (int, string) { return getRaw(t, emailURL(fx, "titogo")+"/events/push", token) }}, |
| 244 | {"PUT events push", func() (int, string) { |
| 245 | return putRaw(t, emailURL(fx, "titogo")+"/events/push", map[string]any{"path": "/mail/events"}, token) |
| 246 | }}, |
| 247 | {"DELETE events push", func() (int, string) { |
| 248 | status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, "titogo")+"/events/push", token, "") |
| 249 | return status, body |
| 250 | }}, |
| 251 | {"POST declare", func() (int, string) { |
| 252 | return postRaw(t, emailURL(fx, "titogo"), map[string]any{"from": "hello@example.com"}, token) |
| 253 | }}, |
| 254 | {"POST domains", func() (int, string) { |
| 255 | return postRaw(t, emailURL(fx, "titogo")+"/domains", map[string]any{"domain": "example.com"}, token) |
| 256 | }}, |
| 257 | {"POST domains verify", func() (int, string) { |
| 258 | return postRaw(t, emailURL(fx, "titogo")+"/domains/example.com/verify", map[string]any{}, token) |
| 259 | }}, |
| 260 | {"DELETE domains", func() (int, string) { |
| 261 | status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, "titogo")+"/domains/example.com", token, "") |
| 262 | return status, body |
| 263 | }}, |
| 264 | {"POST enable", func() (int, string) { |
| 265 | return postRaw(t, emailURL(fx, "titogo")+"/enable", map[string]any{}, token) |
| 266 | }}, |
| 267 | {"POST rotate", func() (int, string) { |
| 268 | return postRaw(t, emailURL(fx, "titogo")+"/rotate", map[string]any{}, token) |
| 269 | }}, |
| 270 | {"POST credentials", func() (int, string) { |
| 271 | return postRaw(t, emailURL(fx, "titogo")+"/credentials", map[string]any{"name": "laptop"}, token) |
| 272 | }}, |
| 273 | {"DELETE credentials", func() (int, string) { |
| 274 | status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, "titogo")+"/credentials/laptop", token, "") |
| 275 | return status, body |
| 276 | }}, |
| 277 | // The pause door needs an operator too — proving the 404 fires |
| 278 | // AFTER operator authority is established, not in place of it. |
| 279 | {"POST pause", func() (int, string) { |
| 280 | return postRaw(t, emailURL(fx, "titogo")+"/pause", map[string]any{"paused": true}, opsToken) |
| 281 | }}, |
| 282 | } |
| 283 | for _, c := range calls { |
| 284 | t.Run(c.name, func(t *testing.T) { |
| 285 | status, body := c.do() |
| 286 | if status != http.StatusNotFound { |
| 287 | t.Fatalf("%s with a nil provisioner = %d %q, want 404 (never a panic)", c.name, status, body) |
| 288 | } |
| 289 | }) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | // TestOperatorStatusBuysNothingOnMemberDoors: I3 moved the pause door off |
| 294 | // apiAccountApp's member gate onto store.IsOperator — this proves that |
| 295 | // move did not leak into the other nine. A fleet operator (IsOperator |
| 296 | // true) who is NOT a member of the target account gets the exact same |
| 297 | // byte-identical 404 an ordinary stranger gets on a WRITE (declare) and a |
| 298 | // READ (status) member door. Operator status must buy nothing here. |
| 299 | func TestOperatorStatusBuysNothingOnMemberDoors(t *testing.T) { |
| 300 | fx, _ := emailFixture(t, "code-email-operator-not-member") |
| 301 | ops := emailOperatorToken(t, fx, "code-email-operator-not-member-ops") |
| 302 | |
| 303 | status, body := postRaw(t, emailURL(fx, "titogo"), map[string]any{"from": "hello@example.com"}, ops) |
| 304 | if status != http.StatusNotFound { |
| 305 | t.Fatalf("declare by a non-member operator = %d %q, want 404", status, body) |
| 306 | } |
| 307 | if body != "404 page not found\n" { |
| 308 | t.Fatalf("declare refusal body = %q, want the stdlib http.NotFound output", body) |
| 309 | } |
| 310 | |
| 311 | status, body = getRaw(t, emailURL(fx, "titogo"), ops) |
| 312 | if status != http.StatusNotFound { |
| 313 | t.Fatalf("status by a non-member operator = %d %q, want 404", status, body) |
| 314 | } |
| 315 | if body != "404 page not found\n" { |
| 316 | t.Fatalf("status refusal body = %q, want the stdlib http.NotFound output", body) |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | // ---- declare and its changed-gating ------------------------------------ |
| 321 | |
| 322 | // TestAPIEmailDeclareWritesTheRecord: the happy path. From is required (a |
| 323 | // bare env_prefix with no From is refused), the record lands in the |
| 324 | // bucket, and declaring never bumps the serial — nothing a box reads has |
| 325 | // changed. |
| 326 | func TestAPIEmailDeclareWritesTheRecord(t *testing.T) { |
| 327 | fx, _, _, token := emailArmed(t, "code-email-declare") |
| 328 | app := "titogo" |
| 329 | if rec := emailRecord(t, fx, app); rec != nil { |
| 330 | t.Fatalf("a record exists before any declaration: %+v", rec) |
| 331 | } |
| 332 | |
| 333 | status, out := postJSON(t, emailURL(fx, app), map[string]any{"from": "hello@titogo.example"}, token) |
| 334 | if status != http.StatusOK { |
| 335 | t.Fatalf("declare = %d %v", status, out) |
| 336 | } |
| 337 | declared := block(t, out, "declared") |
| 338 | if declared["from"] != "hello@titogo.example" || declared["by"] != "alice@keymail.dev" { |
| 339 | t.Fatalf("declared = %v", declared) |
| 340 | } |
| 341 | if declared["env_prefix"] != mail.DefaultEnvPrefix { |
| 342 | t.Fatalf("env_prefix = %v, want the default %q", declared["env_prefix"], mail.DefaultEnvPrefix) |
| 343 | } |
| 344 | if out["grant"] != nil || out["provisioned"] != nil { |
| 345 | t.Fatalf("grant/provisioned = %v/%v, want null before enable", out["grant"], out["provisioned"]) |
| 346 | } |
| 347 | if got := serial(t, fx); got != 0 { |
| 348 | t.Fatalf("serial after a declaration = %d, want 0 — declaring delivers nothing to a box", got) |
| 349 | } |
| 350 | auditedBy(t, fx, app, "email-declare", "alice@keymail.dev", "hello@titogo.example") |
| 351 | } |
| 352 | |
| 353 | // TestAPIEmailStatusCarriesTheDefaultHostEvenWhenNotDeclared (I4, review |
| 354 | // follow-up): `carlos email enable` with no -from needs the app's default |
| 355 | // sending host on its VERY FIRST call — before anything has ever been |
| 356 | // declared, which is exactly the 404 handleAPIEmailStatus gives. The field |
| 357 | // must ride along on that 404, not only on a 200. |
| 358 | func TestAPIEmailStatusCarriesTheDefaultHostEvenWhenNotDeclared(t *testing.T) { |
| 359 | fx, _, _, token := emailArmed(t, "code-email-default-host-404") |
| 360 | app := "titogo" |
| 361 | if rec := emailRecord(t, fx, app); rec != nil { |
| 362 | t.Fatalf("a record exists before any declaration: %+v", rec) |
| 363 | } |
| 364 | |
| 365 | status, out := getJSON(t, emailURL(fx, app), token) |
| 366 | if status != http.StatusNotFound { |
| 367 | t.Fatalf("status = %d %v, want 404 for an undeclared app", status, out) |
| 368 | } |
| 369 | want := "titogo.bab.oncarlos.com" |
| 370 | if got, _ := out["default_host"].(string); got != want { |
| 371 | t.Fatalf("default_host = %q, want %q", got, want) |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | // TestAPIEmailStatusCarriesTheDefaultHostOnceDeclared: the same field on |
| 376 | // the ordinary 200 path, once something has actually been declared. |
| 377 | func TestAPIEmailStatusCarriesTheDefaultHostOnceDeclared(t *testing.T) { |
| 378 | fx, _, _, token := emailArmed(t, "code-email-default-host-200") |
| 379 | app := "titogo" |
| 380 | declareEmail(t, fx, token, app, "hello@titogo.example") |
| 381 | |
| 382 | status, out := getJSON(t, emailURL(fx, app), token) |
| 383 | if status != http.StatusOK { |
| 384 | t.Fatalf("status = %d %v", status, out) |
| 385 | } |
| 386 | want := "titogo.bab.oncarlos.com" |
| 387 | if got, _ := out["default_host"].(string); got != want { |
| 388 | t.Fatalf("default_host = %q, want %q", got, want) |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | // TestAPIEmailStatusDefaultHostEmptyWithNoPlatformSuffix: a deployment with |
| 393 | // no CARLOS_MAIL_PLATFORM_SUFFIX configured has no zone to default into — |
| 394 | // default_host must stay empty, which is what keeps the CLI's explicit |
| 395 | // "no From address" refusal intact rather than defaulting to a bogus host. |
| 396 | func TestAPIEmailStatusDefaultHostEmptyWithNoPlatformSuffix(t *testing.T) { |
| 397 | fx, _, _, token := emailArmed(t, "code-email-default-host-empty") |
| 398 | fx.srv.cfg.MailDeployment.PlatformSuffix = "" |
| 399 | app := "titogo" |
| 400 | |
| 401 | status, out := getJSON(t, emailURL(fx, app), token) |
| 402 | if status != http.StatusNotFound { |
| 403 | t.Fatalf("status = %d %v, want 404 for an undeclared app", status, out) |
| 404 | } |
| 405 | if got, _ := out["default_host"].(string); got != "" { |
| 406 | t.Fatalf("default_host = %q, want empty with no platform suffix configured", got) |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | // TestAPIEmailStatusResumesAStaleCapPause: R2, review follow-up. The SES |
| 411 | // event path (enforceMailLimits) is enforceMailLimits' only production |
| 412 | // caller, and a paused app's IAM key is deactivated — it sends nothing, so |
| 413 | // no SES event ever arrives to run that check. The status READ door must |
| 414 | // run the same day-roll check itself, or an app alone on its own domain |
| 415 | // would see its cap pause as permanent, contradicting what the CLI tells |
| 416 | // the member. Proves the wiring end to end, over HTTP — not just the |
| 417 | // shared function in isolation. |
| 418 | func TestAPIEmailStatusResumesAStaleCapPause(t *testing.T) { |
| 419 | fx, fake, _, token := emailArmed(t, "code-email-status-resume") |
| 420 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 421 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 422 | |
| 423 | rec, etag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, app) |
| 424 | if err != nil { |
| 425 | t.Fatal(err) |
| 426 | } |
| 427 | rec.Provisioned.Active = false |
| 428 | rec.Provisioned.PauseKind = mail.PauseKindCap |
| 429 | rec.Provisioned.At = time.Now().AddDate(0, 0, -1) // yesterday, UTC |
| 430 | if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, etag); err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | |
| 434 | status, out := getJSON(t, emailURL(fx, app), token) |
| 435 | if status != http.StatusOK { |
| 436 | t.Fatalf("status = %d %v", status, out) |
| 437 | } |
| 438 | if out["active"] != true { |
| 439 | t.Fatalf("active = %v, want true — a cap pause from yesterday must clear on a status read", out["active"]) |
| 440 | } |
| 441 | |
| 442 | got := emailRecord(t, fx, app) |
| 443 | if !got.Provisioned.Active { |
| 444 | t.Fatal("the record on disk is still paused after the status read resumed it") |
| 445 | } |
| 446 | if got.Provisioned.PauseKind != "" { |
| 447 | t.Fatalf("PauseKind = %q on disk after resume, want cleared", got.Provisioned.PauseKind) |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | // TestAPIEmailStatusLeavesARatePauseAlone: the same door must NEVER resume |
| 452 | // a RATE pause — that pause exists because the tenant is abusive, not |
| 453 | // because a budget rolled over, and it needs a human. |
| 454 | func TestAPIEmailStatusLeavesARatePauseAlone(t *testing.T) { |
| 455 | fx, fake, _, token := emailArmed(t, "code-email-status-no-resume-rate") |
| 456 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 457 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 458 | |
| 459 | rec, etag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, app) |
| 460 | if err != nil { |
| 461 | t.Fatal(err) |
| 462 | } |
| 463 | rec.Provisioned.Active = false |
| 464 | rec.Provisioned.PauseKind = mail.PauseKindRate |
| 465 | rec.Provisioned.At = time.Now().AddDate(0, 0, -1) // yesterday, UTC |
| 466 | if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, etag); err != nil { |
| 467 | t.Fatal(err) |
| 468 | } |
| 469 | |
| 470 | status, out := getJSON(t, emailURL(fx, app), token) |
| 471 | if status != http.StatusOK { |
| 472 | t.Fatalf("status = %d %v", status, out) |
| 473 | } |
| 474 | if out["active"] != false { |
| 475 | t.Fatalf("active = %v, want false — a rate pause must never auto-resume", out["active"]) |
| 476 | } |
| 477 | |
| 478 | got := emailRecord(t, fx, app) |
| 479 | if got.Provisioned.Active { |
| 480 | t.Fatal("the record on disk was resumed — a rate pause must never auto-resume") |
| 481 | } |
| 482 | if got.Provisioned.PauseKind != mail.PauseKindRate { |
| 483 | t.Fatalf("PauseKind = %q on disk, want it to survive as %q", got.Provisioned.PauseKind, mail.PauseKindRate) |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | // findSender pulls one sender's entry out of a decoded status reply's |
| 488 | // senders array by id. Over HTTP the array decodes as []any of |
| 489 | // map[string]any, unlike the in-process emailReply callers that see |
| 490 | // []map[string]any directly. |
| 491 | func findSender(t *testing.T, out map[string]any, id string) map[string]any { |
| 492 | t.Helper() |
| 493 | raw, _ := out["senders"].([]any) |
| 494 | for _, r := range raw { |
| 495 | if m, ok := r.(map[string]any); ok && m["id"] == id { |
| 496 | return m |
| 497 | } |
| 498 | } |
| 499 | t.Fatalf("no sender %q in %#v", id, out["senders"]) |
| 500 | return nil |
| 501 | } |
| 502 | |
| 503 | // putSiblingSender writes a second, non-app sender straight to its own |
| 504 | // object, the way step 5's per-customer instance enable will, already |
| 505 | // carrying a pause so a status-read test can seed one without going through |
| 506 | // a walk that does not exist yet. |
| 507 | func putSiblingSender(t *testing.T, fx *consoleFixture, app, pauseKind string, pausedAt time.Time) { |
| 508 | t.Helper() |
| 509 | if _, err := mail.PutSender(context.Background(), fx.rel, release.OpsAccount, app, &mail.Sender{ |
| 510 | ID: "book.example.com", Scope: mail.ScopeInstance, Source: mail.SourceDomain, Host: "book.example.com", |
| 511 | Declared: &mail.Declared{EnvPrefix: "TITOGO_SMTP", From: "noreply@book.example.com", At: pausedAt, By: "o@example.test"}, |
| 512 | Grant: &mail.GrantBlock{Domains: []string{"book.example.com"}, DailyCap: 50, Source: "tier:free", At: pausedAt, By: "tier"}, |
| 513 | Provisioned: &mail.Provisioned{ |
| 514 | IAMUser: "sibling-user", KeyID: "AKIA-SIB", Active: false, |
| 515 | PauseKind: pauseKind, At: pausedAt, |
| 516 | }, |
| 517 | }, ""); err != nil { |
| 518 | t.Fatal(err) |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | // TestAPIEmailStatusResumesAStaleCapPauseOnANonAppSender: task 13 review, |
| 523 | // round 2. The event path (enforceMailLimits) never runs for a sender that |
| 524 | // is already paused, because a paused sender's IAM key sends nothing and so |
| 525 | // generates no SES event to run it. The status door's own resume used to |
| 526 | // look at the app sender only; it must do the same day-roll check for every |
| 527 | // sender it is about to report, or a per-customer instance sender (step 5) |
| 528 | // would pause at its cap and never resume, since nothing else ever reads it. |
| 529 | func TestAPIEmailStatusResumesAStaleCapPauseOnANonAppSender(t *testing.T) { |
| 530 | fx, fake, _, token := emailArmed(t, "code-email-status-resume-sibling") |
| 531 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 532 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 533 | putSiblingSender(t, fx, app, mail.PauseKindCap, time.Now().AddDate(0, 0, -1)) |
| 534 | |
| 535 | status, out := getJSON(t, emailURL(fx, app), token) |
| 536 | if status != http.StatusOK { |
| 537 | t.Fatalf("status = %d %v", status, out) |
| 538 | } |
| 539 | |
| 540 | // The app sender's own top-level fields are exactly as before: the |
| 541 | // sibling's pause and resume must not touch them. |
| 542 | if out["active"] != true || out["daily_cap"] != float64(50) { |
| 543 | t.Fatalf("the top-level fields moved: active=%v daily_cap=%v", out["active"], out["daily_cap"]) |
| 544 | } |
| 545 | |
| 546 | sibling := findSender(t, out, "book.example.com") |
| 547 | if sibling["active"] != true { |
| 548 | t.Fatalf("sibling active = %v, want true. a cap pause from yesterday must clear on a status read, same as the app sender", sibling["active"]) |
| 549 | } |
| 550 | if _, ok := sibling["pause_kind"]; ok { |
| 551 | t.Fatalf("sibling pause_kind = %v, want absent once resumed", sibling["pause_kind"]) |
| 552 | } |
| 553 | |
| 554 | sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, app, "book.example.com") |
| 555 | if err != nil { |
| 556 | t.Fatal(err) |
| 557 | } |
| 558 | if sn.Provisioned == nil || !sn.Provisioned.Active { |
| 559 | t.Fatal("the sibling sender on disk is still paused after the status read resumed it") |
| 560 | } |
| 561 | if sn.Provisioned.PauseKind != "" { |
| 562 | t.Fatalf("PauseKind = %q on disk after resume, want cleared", sn.Provisioned.PauseKind) |
| 563 | } |
| 564 | } |
| 565 | |
| 566 | // TestAPIEmailStatusLeavesARatePauseAloneOnANonAppSender: the same door must |
| 567 | // never resume a RATE pause on a sibling sender either. That pause exists |
| 568 | // because the sender is abusive, not because a budget rolled over, and it |
| 569 | // needs a human, whichever sender it sits on. |
| 570 | func TestAPIEmailStatusLeavesARatePauseAloneOnANonAppSender(t *testing.T) { |
| 571 | fx, fake, _, token := emailArmed(t, "code-email-status-no-resume-rate-sibling") |
| 572 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 573 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 574 | putSiblingSender(t, fx, app, mail.PauseKindRate, time.Now().AddDate(0, 0, -1)) |
| 575 | |
| 576 | status, out := getJSON(t, emailURL(fx, app), token) |
| 577 | if status != http.StatusOK { |
| 578 | t.Fatalf("status = %d %v", status, out) |
| 579 | } |
| 580 | |
| 581 | sibling := findSender(t, out, "book.example.com") |
| 582 | if sibling["active"] != false { |
| 583 | t.Fatalf("sibling active = %v, want false. a rate pause must never auto-resume, sibling or not", sibling["active"]) |
| 584 | } |
| 585 | if sibling["pause_kind"] != mail.PauseKindRate { |
| 586 | t.Fatalf("sibling pause_kind = %v, want %q to survive", sibling["pause_kind"], mail.PauseKindRate) |
| 587 | } |
| 588 | |
| 589 | sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, app, "book.example.com") |
| 590 | if err != nil { |
| 591 | t.Fatal(err) |
| 592 | } |
| 593 | if sn.Provisioned == nil || sn.Provisioned.Active { |
| 594 | t.Fatal("the sibling sender on disk was resumed. a rate pause must never auto-resume") |
| 595 | } |
| 596 | if sn.Provisioned.PauseKind != mail.PauseKindRate { |
| 597 | t.Fatalf("PauseKind = %q on disk, want it to survive as %q", sn.Provisioned.PauseKind, mail.PauseKindRate) |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | // TestAPIEmailDeclareRefusesAnUnaddressedFrom: a From with no "@" is refused |
| 602 | // before anything is written. |
| 603 | func TestAPIEmailDeclareRefusesAnUnaddressedFrom(t *testing.T) { |
| 604 | fx, _, _, token := emailArmed(t, "code-email-declare-badfrom") |
| 605 | status, out := postJSON(t, emailURL(fx, "titogo"), map[string]any{"from": "not-an-address"}, token) |
| 606 | if status != http.StatusBadRequest { |
| 607 | t.Fatalf("declare with a bad From = %d %v, want 400", status, out) |
| 608 | } |
| 609 | if rec := emailRecord(t, fx, "titogo"); rec != nil { |
| 610 | t.Fatalf("a refused declaration wrote a record: %+v", rec) |
| 611 | } |
| 612 | } |
| 613 | |
| 614 | // TestNoOpDeclareDoesNotBumpTheSerial: a re-declare of the SAME env prefix |
| 615 | // and From writes no audit line — declaring never bumps the serial either |
| 616 | // way, changed or not, but the audit trail must not be buried under a no-op |
| 617 | // re-run. A declaration that DOES change something still audits. |
| 618 | func TestNoOpDeclareDoesNotBumpTheSerial(t *testing.T) { |
| 619 | fx, _, _, token := emailArmed(t, "code-email-noop") |
| 620 | app := "titogo" |
| 621 | body := map[string]any{"env_prefix": "TITOGO_SMTP", "from": "hello@titogo.example"} |
| 622 | |
| 623 | status, out := postJSON(t, emailURL(fx, app), body, token) |
| 624 | if status != http.StatusOK { |
| 625 | t.Fatalf("declare = %d %v", status, out) |
| 626 | } |
| 627 | if got := serial(t, fx); got != 0 { |
| 628 | t.Fatalf("serial after declare = %d, want 0", got) |
| 629 | } |
| 630 | before, err := fx.st.Events(OpsAccountID, app, 20) |
| 631 | if err != nil { |
| 632 | t.Fatal(err) |
| 633 | } |
| 634 | |
| 635 | // A no-op re-declare: identical prefix, identical From. |
| 636 | status, out = postJSON(t, emailURL(fx, app), body, token) |
| 637 | if status != http.StatusOK { |
| 638 | t.Fatalf("no-op re-declare = %d %v", status, out) |
| 639 | } |
| 640 | if got := serial(t, fx); got != 0 { |
| 641 | t.Fatalf("serial after a no-op re-declare = %d, want 0", got) |
| 642 | } |
| 643 | after, err := fx.st.Events(OpsAccountID, app, 20) |
| 644 | if err != nil { |
| 645 | t.Fatal(err) |
| 646 | } |
| 647 | if len(after) != len(before) { |
| 648 | t.Fatalf("event count = %d after a no-op re-declare, want unchanged from %d\nbefore=%+v\nafter=%+v", |
| 649 | len(after), len(before), before, after) |
| 650 | } |
| 651 | |
| 652 | // A genuinely changed re-declare DOES audit — the gating is on the |
| 653 | // content, not a blanket "never audit a re-declare". |
| 654 | body["from"] = "other@titogo.example" |
| 655 | status, out = postJSON(t, emailURL(fx, app), body, token) |
| 656 | if status != http.StatusOK { |
| 657 | t.Fatalf("changed re-declare = %d %v", status, out) |
| 658 | } |
| 659 | changed, err := fx.st.Events(OpsAccountID, app, 20) |
| 660 | if err != nil { |
| 661 | t.Fatal(err) |
| 662 | } |
| 663 | if len(changed) != len(after)+1 { |
| 664 | t.Fatalf("event count = %d after a changed re-declare, want %d", len(changed), len(after)+1) |
| 665 | } |
| 666 | } |
| 667 | |
| 668 | // ---- domains -------------------------------------------------------------- |
| 669 | |
| 670 | // TestAPIEmailDomainEnsurePublishesForAPlatformDomain: a domain under this |
| 671 | // deployment's own suffix is auto-published — pending is empty, and the |
| 672 | // records actually landed in the fake DNS writer. |
| 673 | func TestAPIEmailDomainEnsurePublishesForAPlatformDomain(t *testing.T) { |
| 674 | fx, _, writer, token := emailArmed(t, "code-email-domain-platform") |
| 675 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 676 | declareEmail(t, fx, token, app, "hello@"+domain) |
| 677 | |
| 678 | out := ensureEmailDomain(t, fx, token, app, domain) |
| 679 | dom := block(t, out, "domain") |
| 680 | if dom["kind"] != mail.KindPlatform { |
| 681 | t.Fatalf("kind = %v, want platform", dom["kind"]) |
| 682 | } |
| 683 | if pending := pendingList(t, out); len(pending) != 0 { |
| 684 | t.Fatalf("pending = %v, want empty — a platform domain publishes itself", pending) |
| 685 | } |
| 686 | if len(writer.Records) == 0 { |
| 687 | t.Fatal("no records landed in the DNS writer for a platform domain") |
| 688 | } |
| 689 | auditedBy(t, fx, app, "email-domain-ensure", "alice@keymail.dev", domain) |
| 690 | if got := serial(t, fx); got != 0 { |
| 691 | t.Fatalf("serial after ensuring a domain = %d, want 0 — nothing in the app's own config changed", got) |
| 692 | } |
| 693 | } |
| 694 | |
| 695 | // TestAPIEmailDomainEnsureReturnsPendingForACustomDomain: a domain outside |
| 696 | // this deployment's suffix is never auto-published — the member gets the |
| 697 | // record set back to publish by hand, and nothing lands in the DNS writer. |
| 698 | func TestAPIEmailDomainEnsureReturnsPendingForACustomDomain(t *testing.T) { |
| 699 | fx, _, writer, token := emailArmed(t, "code-email-domain-custom") |
| 700 | app, domain := "titogo", "mail.example.com" |
| 701 | declareEmail(t, fx, token, app, "hello@"+domain) |
| 702 | |
| 703 | out := ensureEmailDomain(t, fx, token, app, domain) |
| 704 | dom := block(t, out, "domain") |
| 705 | if dom["kind"] != mail.KindCustom { |
| 706 | t.Fatalf("kind = %v, want custom", dom["kind"]) |
| 707 | } |
| 708 | if pending := pendingList(t, out); len(pending) == 0 { |
| 709 | t.Fatal("pending is empty for a custom domain — the member has nothing to publish") |
| 710 | } |
| 711 | if len(writer.Records) != 0 { |
| 712 | t.Fatalf("records landed in the DNS writer for a CUSTOM domain: %v", writer.Records) |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | // TestAPIEmailDomainEnsureWithNilDNSWriterDegradesPublishingOnly proves |
| 717 | // mailDeploymentFor's corrected adaptation (I4, review round 2): a |
| 718 | // deployment with no DNSWriter must not panic on a platform-suffixed |
| 719 | // domain, and it must hand back pending records for the member to publish |
| 720 | // by hand — Config.DNSWriter's own documented contract — but Kind must stay |
| 721 | // "platform", NOT get silently relabeled "custom". The first cut of this |
| 722 | // fix cleared PlatformSuffix to sidestep the nil writer, which also turned |
| 723 | // off C1's ownership check for this whole deployment shape (with no suffix, |
| 724 | // DomainKindFor can only ever answer custom, so platformNameOwnedBy never |
| 725 | // runs at all). Kind staying "platform" is what proves the ownership check |
| 726 | // ran — see TestAPIEmailDomainEnsureRefusesAPlatformNameWithNoConfiguredWriter |
| 727 | // for the direct security assertion. |
| 728 | func TestAPIEmailDomainEnsureWithNilDNSWriterDegradesPublishingOnly(t *testing.T) { |
| 729 | fx, _, _, token := emailArmed(t, "code-email-domain-nowriter") |
| 730 | fx.srv.cfg.DNSWriter = nil |
| 731 | app, domain := "titogo", "titogo.bab.oncarlos.com" // under the platform suffix, owned by fx.sqid ("bab") |
| 732 | declareEmail(t, fx, token, app, "hello@"+domain) |
| 733 | |
| 734 | out := ensureEmailDomain(t, fx, token, app, domain) |
| 735 | dom := block(t, out, "domain") |
| 736 | if dom["kind"] != mail.KindPlatform { |
| 737 | t.Fatalf("kind = %v, want platform — no DNSWriter must degrade PUBLISHING only, never the domain's kind", dom["kind"]) |
| 738 | } |
| 739 | if pending := pendingList(t, out); len(pending) == 0 { |
| 740 | t.Fatal("pending is empty — a platform domain with no DNSWriter must still hand back records") |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | // TestAPIEmailDomainEnsureRefusesAPlatformNameWithNoConfiguredWriter (I4, |
| 745 | // review round 2): the ownership check must run even in a deployment with |
| 746 | // no DNSWriter configured. Before this fix, mailDeploymentFor cleared |
| 747 | // PlatformSuffix whenever DNSWriter was nil, which silently disabled |
| 748 | // platformNameOwnedBy for the whole deployment — any account could take |
| 749 | // the global claim on any OTHER account's platform name in that shape, |
| 750 | // locking the real owner out of their own name (denial-of-name; no DKIM |
| 751 | // gets published, so not a send capability, but still a 409 against the |
| 752 | // rightful owner). |
| 753 | func TestAPIEmailDomainEnsureRefusesAPlatformNameWithNoConfiguredWriter(t *testing.T) { |
| 754 | fx, _, _, token := emailArmed(t, "code-email-domain-nowriter-ownership") |
| 755 | fx.srv.cfg.DNSWriter = nil |
| 756 | app := "titogo" |
| 757 | declareEmail(t, fx, token, app, "hello@x.aaa.oncarlos.com") |
| 758 | |
| 759 | status, out := postJSON(t, emailURL(fx, app)+"/domains", map[string]any{"domain": "x.aaa.oncarlos.com"}, token) |
| 760 | if status != http.StatusBadRequest { |
| 761 | t.Fatalf("ensure a platform name for a DIFFERENT account (aaa), fx account is bab = %d, want 400: %v", status, out) |
| 762 | } |
| 763 | } |
| 764 | |
| 765 | // TestAPIEmailDomainVerifyRequiresAnEnsuredDomain: verifying a domain that |
| 766 | // was never ensured is a speaking 404, not a crash on a missing region. |
| 767 | func TestAPIEmailDomainVerifyRequiresAnEnsuredDomain(t *testing.T) { |
| 768 | fx, _, _, token := emailArmed(t, "code-email-verify-missing") |
| 769 | status, out := postJSON(t, emailURL(fx, "titogo")+"/domains/never-ensured.example.com/verify", map[string]any{}, token) |
| 770 | if status != http.StatusNotFound { |
| 771 | t.Fatalf("verify an unensured domain = %d %v, want 404", status, out) |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | // TestAPIEmailDomainVerifyRecordsSESVerdict: driving the fake to verified |
| 776 | // and calling verify moves the domain's region to StatusVerified. |
| 777 | func TestAPIEmailDomainVerifyRecordsSESVerdict(t *testing.T) { |
| 778 | fx, fake, _, token := emailArmed(t, "code-email-verify") |
| 779 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 780 | declareEmail(t, fx, token, app, "hello@"+domain) |
| 781 | fake.StatusOnEnsure = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"} |
| 782 | ensureEmailDomain(t, fx, token, app, domain) |
| 783 | |
| 784 | status, out := postJSON(t, emailURL(fx, app)+"/domains/"+domain+"/verify", map[string]any{}, token) |
| 785 | if status != http.StatusOK { |
| 786 | t.Fatalf("verify = %d %v", status, out) |
| 787 | } |
| 788 | dom := block(t, out, "domain") |
| 789 | regions, _ := dom["regions"].(map[string]any) |
| 790 | region, _ := regions["eu-west-1"].(map[string]any) |
| 791 | if region["status"] != mail.StatusVerified { |
| 792 | t.Fatalf("region status = %v, want verified", region["status"]) |
| 793 | } |
| 794 | auditedBy(t, fx, app, "email-domain-verify", "alice@keymail.dev", "verified") |
| 795 | } |
| 796 | |
| 797 | // ---- domains rm: the release door ----------------------------------------- |
| 798 | |
| 799 | // removeEmailDomain drives the DELETE door and hands back the status and |
| 800 | // the decoded reply — a 200 and a refusal are both interesting here, so it |
| 801 | // never fails the test itself. |
| 802 | func removeEmailDomain(t *testing.T, fx *consoleFixture, token, app, domain string) (int, map[string]any) { |
| 803 | t.Helper() |
| 804 | status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, app)+"/domains/"+domain, token, "") |
| 805 | out := map[string]any{} |
| 806 | if err := json.Unmarshal([]byte(body), &out); err != nil { |
| 807 | t.Fatalf("remove %s reply is not JSON (%d): %q", domain, status, body) |
| 808 | } |
| 809 | return status, out |
| 810 | } |
| 811 | |
| 812 | // secondApp claims another app in the same account, so a domain can have |
| 813 | // two referrers — the state the whole sibling rule exists for. |
| 814 | func secondApp(t *testing.T, fx *consoleFixture, name string) string { |
| 815 | t.Helper() |
| 816 | if _, err := fx.st.CreateAppIn(OpsAccountID, name, "paul@keymail.dev", time.Now()); err != nil { |
| 817 | t.Fatal(err) |
| 818 | } |
| 819 | return name |
| 820 | } |
| 821 | |
| 822 | // TestAPIEmailDomainRemoveReleasesTheGlobalClaim: the defect this door |
| 823 | // exists for. A claimed domain was unreachable to every other CARLOS |
| 824 | // account — including the one that actually controls its DNS — until the |
| 825 | // app holding it was PERMANENTLY DELETED, because mail.Teardown was the |
| 826 | // only caller that could ever release a claim. After this, the claim object |
| 827 | // is gone and the domain is free. |
| 828 | func TestAPIEmailDomainRemoveReleasesTheGlobalClaim(t *testing.T) { |
| 829 | fx, _, _, token := emailArmed(t, "code-email-domain-rm-claim") |
| 830 | app, domain := "titogo", "mail.example.com" |
| 831 | declareEmail(t, fx, token, app, "hello@elsewhere.example.com") |
| 832 | ensureEmailDomain(t, fx, token, app, domain) |
| 833 | |
| 834 | claims, err := mail.ListDomainClaims(context.Background(), fx.srv.cfg.Releases) |
| 835 | if err != nil || len(claims) != 1 || claims[0].Domain != domain { |
| 836 | t.Fatalf("fixture broken: claims = %+v (err %v)", claims, err) |
| 837 | } |
| 838 | |
| 839 | status, out := removeEmailDomain(t, fx, token, app, domain) |
| 840 | if status != http.StatusOK { |
| 841 | t.Fatalf("remove = %d %v, want 200", status, out) |
| 842 | } |
| 843 | if out["torn_down"] != true { |
| 844 | t.Fatalf("torn_down = %v, want true — this was the only referrer", out["torn_down"]) |
| 845 | } |
| 846 | claims, err = mail.ListDomainClaims(context.Background(), fx.srv.cfg.Releases) |
| 847 | if err != nil || len(claims) != 0 { |
| 848 | t.Fatalf("the global claim survived the remove: %+v (err %v)", claims, err) |
| 849 | } |
| 850 | auditedBy(t, fx, app, "email-domain-remove", "alice@keymail.dev", domain) |
| 851 | if got := serial(t, fx); got != 0 { |
| 852 | t.Fatalf("serial after removing a domain = %d, want 0 — nothing in the app's own config changed", got) |
| 853 | } |
| 854 | } |
| 855 | |
| 856 | // TestAPIEmailDomainRemoveKeepsADomainASiblingAppUses: with another app |
| 857 | // still referring to the domain, the door removes ONLY the referral. The |
| 858 | // identity, the DNS and the claim all stay — releasing them here would |
| 859 | // revoke a sibling app's verified domain silently. |
| 860 | func TestAPIEmailDomainRemoveKeepsADomainASiblingAppUses(t *testing.T) { |
| 861 | fx, fake, _, token := emailArmed(t, "code-email-domain-rm-sibling") |
| 862 | domain := "shared.example.com" |
| 863 | other := secondApp(t, fx, "marketing") |
| 864 | declareEmail(t, fx, token, "titogo", "hello@elsewhere.example.com") |
| 865 | ensureEmailDomain(t, fx, token, "titogo", domain) |
| 866 | ensureEmailDomain(t, fx, token, other, domain) |
| 867 | |
| 868 | status, out := removeEmailDomain(t, fx, token, "titogo", domain) |
| 869 | if status != http.StatusOK { |
| 870 | t.Fatalf("remove = %d %v, want 200", status, out) |
| 871 | } |
| 872 | if out["torn_down"] != false { |
| 873 | t.Fatalf("torn_down = %v, want false — %s still uses the domain", out["torn_down"], other) |
| 874 | } |
| 875 | if got := strList(t, out, "remaining_apps"); len(got) != 1 || got[0] != other { |
| 876 | t.Fatalf("remaining_apps = %v, want [%s]", got, other) |
| 877 | } |
| 878 | claims, err := mail.ListDomainClaims(context.Background(), fx.srv.cfg.Releases) |
| 879 | if err != nil || len(claims) != 1 { |
| 880 | t.Fatalf("the claim was released while a sibling app still sends from the domain: %+v (err %v)", claims, err) |
| 881 | } |
| 882 | if len(fake.Identities) == 0 { |
| 883 | t.Fatal("the sibling app's SES identity was destroyed") |
| 884 | } |
| 885 | } |
| 886 | |
| 887 | // TestAPIEmailDomainRemoveRefusesTheAppsOwnFromDomain: mail.Enable rejects a |
| 888 | // From that is not at a granted domain, so removing the From's own domain |
| 889 | // would break sending at the app's next re-enable rather than at the |
| 890 | // command that caused it. 409, naming the From and the verb that moves it. |
| 891 | func TestAPIEmailDomainRemoveRefusesTheAppsOwnFromDomain(t *testing.T) { |
| 892 | fx, _, _, token := emailArmed(t, "code-email-domain-rm-from") |
| 893 | app, domain := "titogo", "mail.example.com" |
| 894 | declareEmail(t, fx, token, app, "hello@"+domain) |
| 895 | ensureEmailDomain(t, fx, token, app, domain) |
| 896 | |
| 897 | status, out := removeEmailDomain(t, fx, token, app, domain) |
| 898 | if status != http.StatusConflict { |
| 899 | t.Fatalf("remove of the From's own domain = %d %v, want 409", status, out) |
| 900 | } |
| 901 | msg, _ := out["error"].(string) |
| 902 | if !strings.Contains(msg, "hello@"+domain) { |
| 903 | t.Fatalf("refusal does not name the From address: %q", msg) |
| 904 | } |
| 905 | if !strings.Contains(msg, "--from") { |
| 906 | t.Fatalf("refusal does not name the verb that moves the From: %q", msg) |
| 907 | } |
| 908 | claims, err := mail.ListDomainClaims(context.Background(), fx.srv.cfg.Releases) |
| 909 | if err != nil || len(claims) != 1 { |
| 910 | t.Fatalf("a refused remove still released the claim: %+v (err %v)", claims, err) |
| 911 | } |
| 912 | if rec, _, err := mail.GetDomain(context.Background(), fx.srv.cfg.Releases, fx.sqid, domain); err != nil || rec == nil { |
| 913 | t.Fatalf("a refused remove still deleted the domain record: %+v (err %v)", rec, err) |
| 914 | } |
| 915 | } |
| 916 | |
| 917 | // TestAPIEmailDomainRemoveIs404ForADomainTheAppDoesNotUse: naming a domain |
| 918 | // this app does not send from is a 404, never a quiet 200. The same 404 |
| 919 | // covers a domain claimed by ANOTHER account, deliberately in the same |
| 920 | // words — see mail.ErrDomainNotReferenced. |
| 921 | func TestAPIEmailDomainRemoveIs404ForADomainTheAppDoesNotUse(t *testing.T) { |
| 922 | fx, _, _, token := emailArmed(t, "code-email-domain-rm-404") |
| 923 | app := "titogo" |
| 924 | declareEmail(t, fx, token, app, "hello@elsewhere.example.com") |
| 925 | |
| 926 | status, out := removeEmailDomain(t, fx, token, app, "never-added.example.com") |
| 927 | if status != http.StatusNotFound { |
| 928 | t.Fatalf("remove of an unused domain = %d %v, want 404", status, out) |
| 929 | } |
| 930 | if msg, _ := out["error"].(string); !strings.Contains(msg, "carlos email status") { |
| 931 | t.Fatalf("404 does not point at the verb that lists the app's domains: %q", msg) |
| 932 | } |
| 933 | } |
| 934 | |
| 935 | // TestAPIEmailDomainRemoveReportsACustomDomainsOrphanedRecords: a custom |
| 936 | // domain's records live in the customer's zone and are the customer's to |
| 937 | // remove — mail.Teardown's rule, kept. The door deletes none of them and |
| 938 | // hands the whole set back so the member can see what just went stale. |
| 939 | func TestAPIEmailDomainRemoveReportsACustomDomainsOrphanedRecords(t *testing.T) { |
| 940 | fx, _, writer, token := emailArmed(t, "code-email-domain-rm-custom-dns") |
| 941 | app, domain := "titogo", "mail.example.com" |
| 942 | declareEmail(t, fx, token, app, "hello@elsewhere.example.com") |
| 943 | ensureEmailDomain(t, fx, token, app, domain) |
| 944 | if len(writer.Records) != 0 { |
| 945 | t.Fatalf("fixture broken: CARLOS published a custom domain's records: %v", writer.Records) |
| 946 | } |
| 947 | |
| 948 | status, out := removeEmailDomain(t, fx, token, app, domain) |
| 949 | if status != http.StatusOK { |
| 950 | t.Fatalf("remove = %d %v, want 200", status, out) |
| 951 | } |
| 952 | orphaned, ok := out["orphaned"].([]any) |
| 953 | if !ok || len(orphaned) != 6 { |
| 954 | t.Fatalf("orphaned = %v, want the whole six-record set back", out["orphaned"]) |
| 955 | } |
| 956 | } |
| 957 | |
| 958 | // TestAPIEmailDomainRemoveDeletesAPlatformDomainsRecords: the other half of |
| 959 | // the same rule — records CARLOS published, CARLOS removes, and reports |
| 960 | // nothing orphaned because nothing is. |
| 961 | func TestAPIEmailDomainRemoveDeletesAPlatformDomainsRecords(t *testing.T) { |
| 962 | fx, _, writer, token := emailArmed(t, "code-email-domain-rm-platform-dns") |
| 963 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 964 | declareEmail(t, fx, token, app, "hello@elsewhere.example.com") |
| 965 | ensureEmailDomain(t, fx, token, app, domain) |
| 966 | if len(writer.Records) == 0 { |
| 967 | t.Fatal("fixture broken: nothing was published for a platform domain") |
| 968 | } |
| 969 | |
| 970 | status, out := removeEmailDomain(t, fx, token, app, domain) |
| 971 | if status != http.StatusOK { |
| 972 | t.Fatalf("remove = %d %v, want 200", status, out) |
| 973 | } |
| 974 | if len(writer.Records) != 0 { |
| 975 | t.Fatalf("a platform domain's published records survived: %v", writer.Records) |
| 976 | } |
| 977 | if orphaned, _ := out["orphaned"].([]any); len(orphaned) != 0 { |
| 978 | t.Fatalf("orphaned = %v, want empty for a platform domain", orphaned) |
| 979 | } |
| 980 | } |
| 981 | |
| 982 | // ---- enable and the F2 grant wiring ---------------------------------------- |
| 983 | |
| 984 | // TestEnableAppliesTheTierGrantBeforeProvisioning: F2's ruling, pinned. The |
| 985 | // enable door must leave a `grant` block on the record naming the tier's |
| 986 | // cap — without it the limiter silently runs the app on the default free |
| 987 | // cap with no record of why. |
| 988 | func TestEnableAppliesTheTierGrantBeforeProvisioning(t *testing.T) { |
| 989 | fx, fake, _, token := emailArmed(t, "code-email-grant") |
| 990 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 991 | declareEmail(t, fx, token, app, "hello@"+domain) |
| 992 | fake.StatusOnEnsure = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"} |
| 993 | ensureEmailDomain(t, fx, token, app, domain) |
| 994 | verifyEmailDomain(t, fx, token, app, domain) |
| 995 | |
| 996 | out := enableEmail(t, fx, token, app) |
| 997 | grant := block(t, out, "grant") |
| 998 | if grant["daily_cap"] != float64(TierCap("free")) { |
| 999 | t.Fatalf("grant.daily_cap = %v, want %d", grant["daily_cap"], TierCap("free")) |
| 1000 | } |
| 1001 | if grant["source"] != "tier:free" { |
| 1002 | t.Fatalf("grant.source = %v, want tier:free", grant["source"]) |
| 1003 | } |
| 1004 | if out["daily_cap"] != float64(TierCap("free")) { |
| 1005 | t.Fatalf("daily_cap = %v, want %d", out["daily_cap"], TierCap("free")) |
| 1006 | } |
| 1007 | |
| 1008 | rec := emailRecord(t, fx, app) |
| 1009 | if rec.Grant == nil { |
| 1010 | t.Fatal("record has no grant block after enable") |
| 1011 | } |
| 1012 | if rec.Grant.DailyCap != TierCap("free") || rec.Grant.Source != "tier:free" { |
| 1013 | t.Fatalf("record grant = %+v, want cap %d source tier:free", rec.Grant, TierCap("free")) |
| 1014 | } |
| 1015 | if rec.Provisioned == nil || rec.Provisioned.KeyID == "" { |
| 1016 | t.Fatalf("record provisioned = %+v, want a delivered credential", rec.Provisioned) |
| 1017 | } |
| 1018 | auditedBy(t, fx, app, "email-enable", "alice@keymail.dev", "enabled") |
| 1019 | if got := serial(t, fx); got == 0 { |
| 1020 | t.Fatal("serial did not bump after enable delivered config") |
| 1021 | } |
| 1022 | } |
| 1023 | |
| 1024 | // TestAPIEmailEnableRequiresAVerifiedDomain: enabling before any domain has |
| 1025 | // verified is refused, not silently short-circuited. |
| 1026 | func TestAPIEmailEnableRequiresAVerifiedDomain(t *testing.T) { |
| 1027 | fx, _, _, token := emailArmed(t, "code-email-enable-nodomain") |
| 1028 | app := "titogo" |
| 1029 | declareEmail(t, fx, token, app, "hello@titogo.bab.oncarlos.com") |
| 1030 | |
| 1031 | status, out := postJSON(t, emailURL(fx, app)+"/enable", map[string]any{}, token) |
| 1032 | if status != http.StatusConflict { |
| 1033 | t.Fatalf("enable with no verified domain = %d %v, want 409", status, out) |
| 1034 | } |
| 1035 | } |
| 1036 | |
| 1037 | // ---- rotate ----------------------------------------------------------------- |
| 1038 | |
| 1039 | // TestAPIEmailRotateThenFinish: rotate leaves both keys live |
| 1040 | // (pending_finish true) and delivers a new key; finish closes the window |
| 1041 | // and bumps no serial. |
| 1042 | func TestAPIEmailRotateThenFinish(t *testing.T) { |
| 1043 | fx, fake, _, token := emailArmed(t, "code-email-rotate") |
| 1044 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1045 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1046 | firstKey := emailRecord(t, fx, app).Provisioned.KeyID |
| 1047 | |
| 1048 | status, out := postJSON(t, emailURL(fx, app)+"/rotate", map[string]any{}, token) |
| 1049 | if status != http.StatusOK { |
| 1050 | t.Fatalf("rotate = %d %v", status, out) |
| 1051 | } |
| 1052 | if out["pending_finish"] != true { |
| 1053 | t.Fatalf("pending_finish = %v, want true right after a rotate", out["pending_finish"]) |
| 1054 | } |
| 1055 | rec := emailRecord(t, fx, app) |
| 1056 | if rec.Provisioned.KeyID == firstKey { |
| 1057 | t.Fatal("rotate did not mint a new key") |
| 1058 | } |
| 1059 | if rec.Provisioned.PrevKeyID != firstKey { |
| 1060 | t.Fatalf("PrevKeyID = %q, want %q", rec.Provisioned.PrevKeyID, firstKey) |
| 1061 | } |
| 1062 | serialAfterRotate := serial(t, fx) |
| 1063 | if serialAfterRotate == 0 { |
| 1064 | t.Fatal("serial did not bump after rotate delivered a new key") |
| 1065 | } |
| 1066 | |
| 1067 | status, out = postJSON(t, emailURL(fx, app)+"/rotate?finish=1", map[string]any{}, token) |
| 1068 | if status != http.StatusOK { |
| 1069 | t.Fatalf("rotate finish = %d %v", status, out) |
| 1070 | } |
| 1071 | if out["pending_finish"] != false { |
| 1072 | t.Fatalf("pending_finish = %v, want false after finish", out["pending_finish"]) |
| 1073 | } |
| 1074 | if got := serial(t, fx); got != serialAfterRotate { |
| 1075 | t.Fatalf("serial after finish = %d, want unchanged from %d — finish delivers nothing", got, serialAfterRotate) |
| 1076 | } |
| 1077 | } |
| 1078 | |
| 1079 | // ---- standalone credentials ------------------------------------------------- |
| 1080 | |
| 1081 | // TestStandaloneResponseIsNoStoreAndTheOnlyOneWithAPassword: the credential |
| 1082 | // create reply is uncacheable and the only reply in this file carrying a |
| 1083 | // password. Every sibling reply in the same flow is checked and must not. |
| 1084 | func TestStandaloneResponseIsNoStoreAndTheOnlyOneWithAPassword(t *testing.T) { |
| 1085 | fx, fake, _, token := emailArmed(t, "code-email-standalone") |
| 1086 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1087 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1088 | |
| 1089 | status, headers, body := rawReq(t, http.MethodPost, emailURL(fx, app)+"/credentials", token, `{"name":"laptop"}`) |
| 1090 | if status != http.StatusOK { |
| 1091 | t.Fatalf("credentials create = %d %s", status, body) |
| 1092 | } |
| 1093 | if got := headers.Get("Cache-Control"); got != "no-store" { |
| 1094 | t.Fatalf("Cache-Control = %q, want no-store", got) |
| 1095 | } |
| 1096 | var out map[string]any |
| 1097 | if err := json.Unmarshal([]byte(body), &out); err != nil { |
| 1098 | t.Fatalf("body is not JSON: %v\n%s", err, body) |
| 1099 | } |
| 1100 | pass, _ := out["password"].(string) |
| 1101 | if pass == "" { |
| 1102 | t.Fatalf("password = %q, want a real credential", pass) |
| 1103 | } |
| 1104 | user, _ := out["username"].(string) |
| 1105 | if user == "" { |
| 1106 | t.Fatal("username (the key id) is empty") |
| 1107 | } |
| 1108 | |
| 1109 | // No sibling reply in this same flow ever carries the substring |
| 1110 | // "password" — declare, status, enable, rotate. |
| 1111 | _, declareBody := postRaw(t, emailURL(fx, app), map[string]any{"from": "hello@" + domain}, token) |
| 1112 | if strings.Contains(declareBody, "password") { |
| 1113 | t.Fatalf("declare reply carries a password: %s", declareBody) |
| 1114 | } |
| 1115 | _, statusBody := getRaw(t, emailURL(fx, app), token) |
| 1116 | if strings.Contains(statusBody, "password") { |
| 1117 | t.Fatalf("status reply carries a password: %s", statusBody) |
| 1118 | } |
| 1119 | _, enableBody := postRaw(t, emailURL(fx, app)+"/enable", map[string]any{}, token) |
| 1120 | if strings.Contains(enableBody, "password") { |
| 1121 | t.Fatalf("enable reply carries a password: %s", enableBody) |
| 1122 | } |
| 1123 | _, rotateBody := postRaw(t, emailURL(fx, app)+"/rotate", map[string]any{}, token) |
| 1124 | if strings.Contains(rotateBody, "password") { |
| 1125 | t.Fatalf("rotate reply carries a password: %s", rotateBody) |
| 1126 | } |
| 1127 | } |
| 1128 | |
| 1129 | // TestStatusReplyNeverCarriesAPassword: a dedicated check on the status |
| 1130 | // door specifically, with a standalone credential already minted (the one |
| 1131 | // place a password could leak from if rec.Standalone were ever echoed |
| 1132 | // wrong) — key_id is expected and fine; password is not. |
| 1133 | func TestStatusReplyNeverCarriesAPassword(t *testing.T) { |
| 1134 | fx, fake, _, token := emailArmed(t, "code-email-status-password") |
| 1135 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1136 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1137 | if status, out := postJSON(t, emailURL(fx, app)+"/credentials", map[string]any{"name": "laptop"}, token); status != http.StatusOK { |
| 1138 | t.Fatalf("credentials create = %d %v", status, out) |
| 1139 | } |
| 1140 | |
| 1141 | status, body := getRaw(t, emailURL(fx, app), token) |
| 1142 | if status != http.StatusOK { |
| 1143 | t.Fatalf("status = %d %s", status, body) |
| 1144 | } |
| 1145 | if strings.Contains(body, "password") { |
| 1146 | t.Fatalf("status reply carries a password: %s", body) |
| 1147 | } |
| 1148 | var out map[string]any |
| 1149 | if err := json.Unmarshal([]byte(body), &out); err != nil { |
| 1150 | t.Fatalf("body is not JSON: %v", err) |
| 1151 | } |
| 1152 | prov := block(t, out, "provisioned") |
| 1153 | if s, _ := prov["key_id"].(string); s == "" { |
| 1154 | t.Fatalf("provisioned.key_id = %v, want the delivered key id (not a secret)", prov["key_id"]) |
| 1155 | } |
| 1156 | standalone, _ := out["standalone"].([]any) |
| 1157 | if len(standalone) != 1 { |
| 1158 | t.Fatalf("standalone = %v, want the one minted credential", out["standalone"]) |
| 1159 | } |
| 1160 | } |
| 1161 | |
| 1162 | // TestAPIEmailCredentialsRevokeUnknownNameIs404: revoking a name that was |
| 1163 | // never minted is a speaking 404, not a silent no-op 200. |
| 1164 | func TestAPIEmailCredentialsRevokeUnknownNameIs404(t *testing.T) { |
| 1165 | fx, fake, _, token := emailArmed(t, "code-email-revoke-missing") |
| 1166 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1167 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1168 | |
| 1169 | status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, app)+"/credentials/never-minted", token, "") |
| 1170 | if status != http.StatusNotFound { |
| 1171 | t.Fatalf("revoke an unminted credential = %d %s, want 404", status, body) |
| 1172 | } |
| 1173 | } |
| 1174 | |
| 1175 | // TestAPIEmailCredentialsRevokeRemovesIt: revoke actually deletes the |
| 1176 | // credential and does not disturb the delivered one. |
| 1177 | func TestAPIEmailCredentialsRevokeRemovesIt(t *testing.T) { |
| 1178 | fx, fake, _, token := emailArmed(t, "code-email-revoke") |
| 1179 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1180 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1181 | deliveredKey := emailRecord(t, fx, app).Provisioned.KeyID |
| 1182 | if status, out := postJSON(t, emailURL(fx, app)+"/credentials", map[string]any{"name": "laptop"}, token); status != http.StatusOK { |
| 1183 | t.Fatalf("credentials create = %d %v", status, out) |
| 1184 | } |
| 1185 | |
| 1186 | status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, app)+"/credentials/laptop", token, "") |
| 1187 | if status != http.StatusOK { |
| 1188 | t.Fatalf("revoke = %d %s", status, body) |
| 1189 | } |
| 1190 | rec := emailRecord(t, fx, app) |
| 1191 | if len(rec.Standalone) != 0 { |
| 1192 | t.Fatalf("standalone = %+v, want empty after revoke", rec.Standalone) |
| 1193 | } |
| 1194 | if rec.Provisioned.KeyID != deliveredKey { |
| 1195 | t.Fatal("revoking a standalone credential disturbed the delivered one") |
| 1196 | } |
| 1197 | } |
| 1198 | |
| 1199 | // ---- pause (operator) ------------------------------------------------------- |
| 1200 | |
| 1201 | // TestAPIEmailPauseNeedsAnOperator: a member of the account — even the one |
| 1202 | // who declared and enabled — cannot pause or raise a cap. The refusal is |
| 1203 | // the family's byte-identical 404 (I3: this door never speaks a 403 — |
| 1204 | // that would be an oracle telling a non-operator prober the route |
| 1205 | // exists), and nothing is changed. |
| 1206 | func TestAPIEmailPauseNeedsAnOperator(t *testing.T) { |
| 1207 | fx, fake, _, token := emailArmed(t, "code-email-pause-operator") |
| 1208 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1209 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1210 | |
| 1211 | status, body := postRaw(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, token) |
| 1212 | if status != http.StatusNotFound { |
| 1213 | t.Fatalf("pause by a non-operator member = %d %q, want 404", status, body) |
| 1214 | } |
| 1215 | if body != "404 page not found\n" { |
| 1216 | t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", body) |
| 1217 | } |
| 1218 | rec := emailRecord(t, fx, app) |
| 1219 | if !rec.Provisioned.Active { |
| 1220 | t.Fatal("a refused pause changed the record's active state") |
| 1221 | } |
| 1222 | } |
| 1223 | |
| 1224 | // TestAPIEmailPauseOperatorNotOnMailOperatorsListIsRefused (I2): a genuine |
| 1225 | // fleet operator (store.IsOperator true) is STILL refused when |
| 1226 | // CARLOS_MAIL_OPERATORS does not name them — the list is fail-closed, not |
| 1227 | // "any fleet operator may." This is the invariant protecting the SHIPPED |
| 1228 | // deployment shape, where CARLOS_MAIL_OPERATORS is typically unset. |
| 1229 | func TestAPIEmailPauseOperatorNotOnMailOperatorsListIsRefused(t *testing.T) { |
| 1230 | fx, fake, _, token := emailArmed(t, "code-email-pause-notlisted") |
| 1231 | fx.srv.cfg.MailOperators = nil // the shipped-binary default |
| 1232 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1233 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1234 | if err := fx.st.SetOperator("random-operator@keymail.dev", true); err != nil { |
| 1235 | t.Fatal(err) |
| 1236 | } |
| 1237 | randomOp := cliToken(t, fx, "random-operator@keymail.dev", "code-email-pause-notlisted-op") |
| 1238 | |
| 1239 | status, body := postRaw(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, randomOp) |
| 1240 | if status != http.StatusNotFound { |
| 1241 | t.Fatalf("pause by a fleet operator not on MailOperators = %d %q, want 404", status, body) |
| 1242 | } |
| 1243 | rec := emailRecord(t, fx, app) |
| 1244 | if !rec.Provisioned.Active { |
| 1245 | t.Fatal("a refused pause changed the record's active state") |
| 1246 | } |
| 1247 | } |
| 1248 | |
| 1249 | // TestAPIEmailPauseOperatorNeedNotBeAMember (I3): the abuse brake's whole |
| 1250 | // reason for existing — an operator with NO membership in the account can |
| 1251 | // still pause it. Confirms non-membership explicitly, rather than relying |
| 1252 | // on emailOperatorToken never having called AddMember. |
| 1253 | func TestAPIEmailPauseOperatorNeedNotBeAMember(t *testing.T) { |
| 1254 | fx, fake, _, token := emailArmed(t, "code-email-pause-nonmember") |
| 1255 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1256 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1257 | ops := emailOperatorToken(t, fx, "code-email-pause-nonmember-ops") |
| 1258 | |
| 1259 | role, err := fx.st.Role(OpsAccountID, "ops@keymail.dev") |
| 1260 | if err != nil { |
| 1261 | t.Fatal(err) |
| 1262 | } |
| 1263 | if role != "" { |
| 1264 | t.Fatalf("role = %q, want the operator to hold NO role in this account", role) |
| 1265 | } |
| 1266 | |
| 1267 | status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops) |
| 1268 | if status != http.StatusOK { |
| 1269 | t.Fatalf("pause by a non-member operator = %d %v, want 200", status, out) |
| 1270 | } |
| 1271 | if out["active"] != false { |
| 1272 | t.Fatalf("active = %v, want false after a non-member operator's pause", out["active"]) |
| 1273 | } |
| 1274 | } |
| 1275 | |
| 1276 | // TestAPIEmailPauseCapRaiseDoesNotResume (I1): a cap-raise-only request |
| 1277 | // ({"daily_cap": N}, no "paused" field) must not decode a missing Paused |
| 1278 | // as false and silently resume a tenant an earlier request paused. |
| 1279 | func TestAPIEmailPauseCapRaiseDoesNotResume(t *testing.T) { |
| 1280 | fx, fake, _, token := emailArmed(t, "code-email-pause-capnoresume") |
| 1281 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1282 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1283 | ops := emailOperatorToken(t, fx, "code-email-pause-capnoresume-ops") |
| 1284 | |
| 1285 | if status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops); status != http.StatusOK { |
| 1286 | t.Fatalf("pause = %d %v", status, out) |
| 1287 | } |
| 1288 | if emailRecord(t, fx, app).Provisioned.Active { |
| 1289 | t.Fatal("sanity check failed: record is not paused after the pause call") |
| 1290 | } |
| 1291 | |
| 1292 | status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"daily_cap": 5000}, ops) |
| 1293 | if status != http.StatusOK { |
| 1294 | t.Fatalf("cap-raise-only = %d %v", status, out) |
| 1295 | } |
| 1296 | if out["active"] != false { |
| 1297 | t.Fatalf("active = %v, want false — a cap-raise-only request must not resume", out["active"]) |
| 1298 | } |
| 1299 | rec := emailRecord(t, fx, app) |
| 1300 | if rec.Provisioned.Active { |
| 1301 | t.Fatal("a cap-raise-only request resumed a paused tenant") |
| 1302 | } |
| 1303 | if rec.Grant.DailyCap != 5000 || rec.Grant.Source != "operator" { |
| 1304 | t.Fatalf("grant = %+v, want the cap raise to still have applied", rec.Grant) |
| 1305 | } |
| 1306 | } |
| 1307 | |
| 1308 | // TestAPIEmailPauseRefusesAResumeInPerInstanceMode: the operator door |
| 1309 | // answers a permanent conflict, never the generic "try again" 502, when |
| 1310 | // asked to resume an app sender that has moved to per-instance senders. |
| 1311 | // Nothing in this release sets PerInstance itself, so this hand-sets it — |
| 1312 | // the point is that a retry can never succeed here, and the operator needs |
| 1313 | // to be told that plainly rather than sent to retry forever. |
| 1314 | func TestAPIEmailPauseRefusesAResumeInPerInstanceMode(t *testing.T) { |
| 1315 | fx, fake, _, token := emailArmed(t, "code-email-pause-perinstance") |
| 1316 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1317 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1318 | ops := emailOperatorToken(t, fx, "code-email-pause-perinstance-ops") |
| 1319 | |
| 1320 | if status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops); status != http.StatusOK { |
| 1321 | t.Fatalf("pause = %d %v", status, out) |
| 1322 | } |
| 1323 | |
| 1324 | sn, etag, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, app, mail.SenderAppID) |
| 1325 | if err != nil { |
| 1326 | t.Fatal(err) |
| 1327 | } |
| 1328 | sn.PerInstance = true |
| 1329 | if _, err := mail.PutSender(context.Background(), fx.rel, release.OpsAccount, app, sn, etag); err != nil { |
| 1330 | t.Fatal(err) |
| 1331 | } |
| 1332 | |
| 1333 | status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": false}, ops) |
| 1334 | if status != http.StatusConflict { |
| 1335 | t.Fatalf("resume in per-instance mode = %d %v, want 409", status, out) |
| 1336 | } |
| 1337 | msg, _ := out["error"].(string) |
| 1338 | if !strings.Contains(msg, "per-instance") { |
| 1339 | t.Fatalf("error = %q, want it to name per-instance mode", msg) |
| 1340 | } |
| 1341 | if strings.Contains(strings.ToLower(msg), "try again") { |
| 1342 | t.Fatalf("refusal promised a retry would help, but this refusal is permanent: %q", msg) |
| 1343 | } |
| 1344 | if emailRecord(t, fx, app).Provisioned.Active { |
| 1345 | t.Fatal("the refused resume left the record active") |
| 1346 | } |
| 1347 | } |
| 1348 | |
| 1349 | // TestAPIEmailPauseRefusesAnEmptyBody: neither paused nor a positive |
| 1350 | // daily_cap is a request that would change nothing — refused rather than |
| 1351 | // silently answering 200 having done nothing. |
| 1352 | func TestAPIEmailPauseRefusesAnEmptyBody(t *testing.T) { |
| 1353 | fx, fake, _, token := emailArmed(t, "code-email-pause-empty") |
| 1354 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1355 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1356 | ops := emailOperatorToken(t, fx, "code-email-pause-empty-ops") |
| 1357 | |
| 1358 | status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{}, ops) |
| 1359 | if status != http.StatusBadRequest { |
| 1360 | t.Fatalf("empty pause body = %d %v, want 400", status, out) |
| 1361 | } |
| 1362 | } |
| 1363 | |
| 1364 | // TestAPIEmailPauseTogglesTheKeyAndRaisesTheCap: an operator can pause, |
| 1365 | // resume and raise the cap through the same door. Raising the cap applies |
| 1366 | // mail.ApplyGrant with source "operator" (F2), and that grant SURVIVES a |
| 1367 | // later tier-sourced enable — proving the wiring passes the right source |
| 1368 | // rather than re-implementing ApplyGrant's own precedence rule. |
| 1369 | func TestAPIEmailPauseTogglesTheKeyAndRaisesTheCap(t *testing.T) { |
| 1370 | fx, fake, _, token := emailArmed(t, "code-email-pause") |
| 1371 | app, domain := "titogo", "titogo.bab.oncarlos.com" |
| 1372 | provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| 1373 | ops := emailOperatorToken(t, fx, "code-email-pause-ops") |
| 1374 | |
| 1375 | status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops) |
| 1376 | if status != http.StatusOK { |
| 1377 | t.Fatalf("pause = %d %v", status, out) |
| 1378 | } |
| 1379 | if out["active"] != false { |
| 1380 | t.Fatalf("active = %v, want false after a pause", out["active"]) |
| 1381 | } |
| 1382 | rec := emailRecord(t, fx, app) |
| 1383 | if rec.Provisioned.Active { |
| 1384 | t.Fatal("record still active after a pause") |
| 1385 | } |
| 1386 | auditedBy(t, fx, app, "email-pause", "ops@keymail.dev", "paused") |
| 1387 | |
| 1388 | status, out = postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": false, "daily_cap": 5000}, ops) |
| 1389 | if status != http.StatusOK { |
| 1390 | t.Fatalf("resume+raise = %d %v", status, out) |
| 1391 | } |
| 1392 | if out["active"] != true { |
| 1393 | t.Fatalf("active = %v, want true after a resume", out["active"]) |
| 1394 | } |
| 1395 | grant := block(t, out, "grant") |
| 1396 | if grant["daily_cap"] != float64(5000) || grant["source"] != "operator" { |
| 1397 | t.Fatalf("grant = %v, want cap 5000 source operator", grant) |
| 1398 | } |
| 1399 | |
| 1400 | // A later enable's TIER-sourced grant must not silently flatten the |
| 1401 | // operator's raise back to the free cap. |
| 1402 | enableEmail(t, fx, token, app) |
| 1403 | rec = emailRecord(t, fx, app) |
| 1404 | if rec.Grant.Source != "operator" || rec.Grant.DailyCap != 5000 { |
| 1405 | t.Fatalf("grant after a later enable = %+v, want the operator's raise to survive", rec.Grant) |
| 1406 | } |
| 1407 | } |
| 1408 | |
| 1409 | // TestAPIEmailDomainEnsureRefusesADomainClaimedByAnotherAccount (I3, Task |
| 1410 | // 17a review follow-up): a domain another account already claimed must |
| 1411 | // come back as a clean 409, not the door's generic 502 — the refusal is |
| 1412 | // PERMANENT (nothing changes between attempts), so telling the member to |
| 1413 | // retry would be actively misleading. The refusal must also stay neutral: |
| 1414 | // the holding account's sqid must never appear in the response body. |
| 1415 | func TestAPIEmailDomainEnsureRefusesADomainClaimedByAnotherAccount(t *testing.T) { |
| 1416 | fx, _, _, token := emailArmed(t, "code-email-domain-claimed") |
| 1417 | app := "titogo" |
| 1418 | declareEmail(t, fx, token, app, "hello@shared.com") |
| 1419 | ensureEmailDomain(t, fx, token, app, "shared.com") // fx.sqid now holds the claim |
| 1420 | |
| 1421 | // A genuinely SEPARATE account — not just a second app in the same |
| 1422 | // account — attempts the same domain. |
| 1423 | now := time.Now() |
| 1424 | const otherAcctID int64 = 4242 |
| 1425 | otherSqid := accountSqid(otherAcctID) |
| 1426 | if err := fx.st.CreateAccount(otherAcctID, otherSqid, "Other", "carol@keymail.dev", now); err != nil { |
| 1427 | t.Fatal(err) |
| 1428 | } |
| 1429 | if _, err := fx.st.CreateAppIn(otherAcctID, "otherapp", "carol@keymail.dev", now); err != nil { |
| 1430 | t.Fatal(err) |
| 1431 | } |
| 1432 | if err := fx.st.AddMember(otherAcctID, "carol@keymail.dev", RoleOwner, now); err != nil { |
| 1433 | t.Fatal(err) |
| 1434 | } |
| 1435 | otherToken := cliToken(t, fx, "carol@keymail.dev", "code-email-domain-claimed-other") |
| 1436 | otherURL := fx.ts.URL + "/api/cli/apps/" + otherSqid + "/otherapp/email" |
| 1437 | status, _ := postJSON(t, otherURL, map[string]any{"from": "hello@shared.com"}, otherToken) |
| 1438 | if status != http.StatusOK { |
| 1439 | t.Fatalf("declare for the other account = %d", status) |
| 1440 | } |
| 1441 | |
| 1442 | status, out := postJSON(t, otherURL+"/domains", map[string]any{"domain": "shared.com"}, otherToken) |
| 1443 | if status != http.StatusConflict { |
| 1444 | t.Fatalf("ensure domain by a second account = %d, want 409: %v", status, out) |
| 1445 | } |
| 1446 | msg, _ := out["error"].(string) |
| 1447 | if strings.Contains(msg, fx.sqid) { |
| 1448 | t.Fatalf("refusal named the holding account's sqid: %q", msg) |
| 1449 | } |
| 1450 | if strings.Contains(strings.ToLower(msg), "try again") || strings.Contains(strings.ToLower(msg), "re-run") { |
| 1451 | t.Fatalf("refusal promised a retry would help, but this refusal is permanent: %q", msg) |
| 1452 | } |
| 1453 | // Neutral (#216): the refusal must not confirm that another account |
| 1454 | // holds the domain, only that this one cannot have it. |
| 1455 | if msg != mailDomainUnavailableMsg { |
| 1456 | t.Fatalf("refusal = %q, want the neutral %q", msg, mailDomainUnavailableMsg) |
| 1457 | } |
| 1458 | } |
| 1459 | |
| 1460 | // TestAPIEmailDomainEnsureRefusesAForeignIdentityWithProofSteps (#216): a |
| 1461 | // domain already verified in SES outside CARLOS is refused with 409 and the |
| 1462 | // exact TXT record that would prove control, and the identity is untouched. |
| 1463 | func TestAPIEmailDomainEnsureRefusesAForeignIdentityWithProofSteps(t *testing.T) { |
| 1464 | fx, fake, _, token := emailArmed(t, "code-email-domain-foreign") |
| 1465 | app := "titogo" |
| 1466 | declareEmail(t, fx, token, app, "hello@tito.io") |
| 1467 | fake.Identities["eu-west-1|tito.io"] = &mail.MemIdentity{ |
| 1468 | ARN: "arn:aws:ses:eu-west-1:1:identity/tito.io", DKIMTokens: []string{"c1", "c2", "c3"}, |
| 1469 | MailFrom: "bounce.tito.io", ConfigSet: "classic", |
| 1470 | Status: mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}, |
| 1471 | } |
| 1472 | status, out := postJSON(t, emailURL(fx, app)+"/domains", map[string]any{"domain": "tito.io"}, token) |
| 1473 | if status != http.StatusConflict { |
| 1474 | t.Fatalf("ensure a foreign identity = %d, want 409: %v", status, out) |
| 1475 | } |
| 1476 | msg, _ := out["error"].(string) |
| 1477 | if !strings.Contains(msg, mail.ProofRecordName("tito.io")) || !strings.Contains(msg, mail.ProofRecordValue(fx.sqid)) { |
| 1478 | t.Fatalf("refusal does not say how to prove control: %q", msg) |
| 1479 | } |
| 1480 | if got := fake.Identities["eu-west-1|tito.io"]; got.MailFrom != "bounce.tito.io" || got.ConfigSet != "classic" { |
| 1481 | t.Fatalf("the foreign identity was mutated: %+v", got) |
| 1482 | } |
| 1483 | } |
| 1484 | |
| 1485 | // TestAPIEmailEventsReadsTheLog: the door `carlos email events` calls hands |
| 1486 | // back the app's per-message log, narrowed by what the query asks, and |
| 1487 | // refuses a query it cannot read rather than guessing at it. |
| 1488 | func TestAPIEmailEventsReadsTheLog(t *testing.T) { |
| 1489 | fx, _, _, token := emailArmed(t, "code-email-events") |
| 1490 | app := "titogo" |
| 1491 | ctx := context.Background() |
| 1492 | now := time.Now() |
| 1493 | for _, c := range []struct{ to, id, typ string }{ |
| 1494 | {"ann@example.com", "m-ann", mail.EventDelivery}, {"bob@example.com", "m-bob", mail.EventBounce}, |
| 1495 | } { |
| 1496 | ev := mail.Event{Type: c.typ, MessageID: c.id, At: now, Outcomes: []mail.Outcome{{Recipient: c.to}}} |
| 1497 | if err := fx.st.RecordMailMessageEvent(ctx, fx.sqid, app, "sns-"+c.id, ev, true, now); err != nil { |
| 1498 | t.Fatal(err) |
| 1499 | } |
| 1500 | } |
| 1501 | |
| 1502 | status, out := getJSON(t, emailURL(fx, app)+"/events", token) |
| 1503 | if status != http.StatusOK { |
| 1504 | t.Fatalf("status = %d %v", status, out) |
| 1505 | } |
| 1506 | events, _ := out["events"].([]any) |
| 1507 | if len(events) != 2 { |
| 1508 | t.Fatalf("events = %v, want both", out["events"]) |
| 1509 | } |
| 1510 | if days, _ := out["retention_days"].(float64); days != 30 { |
| 1511 | t.Errorf("retention_days = %v, want 30 — the reader should be told how far back it can look", out["retention_days"]) |
| 1512 | } |
| 1513 | |
| 1514 | status, out = getJSON(t, emailURL(fx, app)+"/events?to=BOB%40example.com", token) |
| 1515 | events, _ = out["events"].([]any) |
| 1516 | if status != http.StatusOK || len(events) != 1 { |
| 1517 | t.Fatalf("narrowed to bob: %d %v", status, out) |
| 1518 | } |
| 1519 | if first, _ := events[0].(map[string]any); first["message_id"] != "m-bob" || first["type"] != mail.EventBounce { |
| 1520 | t.Errorf("row = %v", first) |
| 1521 | } |
| 1522 | |
| 1523 | // An app with nothing in its log is an empty list, not a null. |
| 1524 | if status, body := getRaw(t, emailURL(fx, app)+"/events?message=nothing-by-this-id", token); status != http.StatusOK || |
| 1525 | !strings.Contains(body, `"events":[]`) { |
| 1526 | t.Errorf("an empty answer = %d %s", status, body) |
| 1527 | } |
| 1528 | |
| 1529 | // And the narrowing that is not about who: a window and a count. |
| 1530 | old := mail.Event{Type: mail.EventDelivery, MessageID: "m-old", At: now.Add(-48 * time.Hour), Outcomes: []mail.Outcome{{Recipient: "cat@example.com"}}} |
| 1531 | if err := fx.st.RecordMailMessageEvent(ctx, fx.sqid, app, "sns-m-old", old, true, now); err != nil { |
| 1532 | t.Fatal(err) |
| 1533 | } |
| 1534 | since := url.QueryEscape(now.Add(-24 * time.Hour).UTC().Format(time.RFC3339)) |
| 1535 | if status, out := getJSON(t, emailURL(fx, app)+"/events?since="+since, token); status != http.StatusOK { |
| 1536 | t.Fatalf("since: %d %v", status, out) |
| 1537 | } else if events, _ := out["events"].([]any); len(events) != 2 { |
| 1538 | t.Errorf("since the last day = %d events, want the two recent ones: %v", len(events), out["events"]) |
| 1539 | } |
| 1540 | if status, out := getJSON(t, emailURL(fx, app)+"/events?limit=1", token); status != http.StatusOK { |
| 1541 | t.Fatalf("limit: %d %v", status, out) |
| 1542 | } else if events, _ := out["events"].([]any); len(events) != 1 { |
| 1543 | t.Errorf("limit=1 gave %d events", len(events)) |
| 1544 | } |
| 1545 | |
| 1546 | for _, bad := range []string{"since=yesterday", "limit=0", "limit=100000", "limit=many"} { |
| 1547 | if status, body := getRaw(t, emailURL(fx, app)+"/events?"+bad, token); status != http.StatusBadRequest { |
| 1548 | t.Errorf("%s = %d %s, want 400", bad, status, body) |
| 1549 | } |
| 1550 | } |
| 1551 | } |
| 1552 | |
| 1553 | // TestAPIEmailPushTurnsOnAndOff: turning the push on delivers the key an app |
| 1554 | // verifies with into the app's own config, and names where events will go; |
| 1555 | // turning it off takes both back. |
| 1556 | func TestAPIEmailPushTurnsOnAndOff(t *testing.T) { |
| 1557 | fx, _, _, token := emailArmed(t, "code-email-push") |
| 1558 | fx.srv.cfg.AppsDomain = "oncarlos.com" |
| 1559 | app := "titogo" |
| 1560 | push := emailURL(fx, app) + "/events/push" |
| 1561 | |
| 1562 | status, out := getJSON(t, push, token) |
| 1563 | if status != http.StatusOK || out["on"] != false { |
| 1564 | t.Fatalf("before asking: %d %v", status, out) |
| 1565 | } |
| 1566 | |
| 1567 | status, body := putRaw(t, push, map[string]any{"path": "/mail/events"}, token) |
| 1568 | if status != http.StatusOK { |
| 1569 | t.Fatalf("turning it on: %d %s", status, body) |
| 1570 | } |
| 1571 | var on map[string]any |
| 1572 | if err := json.Unmarshal([]byte(body), &on); err != nil { |
| 1573 | t.Fatal(err) |
| 1574 | } |
| 1575 | if on["on"] != true || on["path"] != "/mail/events" || on["url"] != "https://titogo."+fx.sqid+".oncarlos.com/mail/events" { |
| 1576 | t.Errorf("reply = %v", on) |
| 1577 | } |
| 1578 | key, _ := fx.st.MailEventsSigningKey() |
| 1579 | vars := appVars(t, fx, app) |
| 1580 | if v, ok := vars[MailEventsKeyEnv]; !ok || v.Kind != "env" || v.Value != mailEventsPublicKey(key) { |
| 1581 | t.Errorf("%s = %+v, want the verifying key as a plain env var", MailEventsKeyEnv, v) |
| 1582 | } |
| 1583 | if on["public_key"] != mailEventsPublicKey(key) { |
| 1584 | t.Errorf("the reply should name the same key the app was given") |
| 1585 | } |
| 1586 | |
| 1587 | for _, bad := range []string{"", "mail/events", "//elsewhere.example/x", "/a/../b", "/with space"} { |
| 1588 | if status, body := putRaw(t, push, map[string]any{"path": bad}, token); status != http.StatusBadRequest { |
| 1589 | t.Errorf("path %q = %d %s, want 400", bad, status, body) |
| 1590 | } |
| 1591 | } |
| 1592 | |
| 1593 | status, _, body = rawReq(t, http.MethodDelete, push, token, "") |
| 1594 | if status != http.StatusOK || !strings.Contains(body, `"on":false`) { |
| 1595 | t.Fatalf("turning it off: %d %s", status, body) |
| 1596 | } |
| 1597 | if _, ok := appVars(t, fx, app)[MailEventsKeyEnv]; ok { |
| 1598 | t.Errorf("%s was left in the app's config after the push was turned off", MailEventsKeyEnv) |
| 1599 | } |
| 1600 | } |
| 1601 | |
| 1602 | // TestAPIEmailPushNeedsSomewhereToSend: a deployment with no apps domain has |
| 1603 | // no platform host to deliver to, and says so rather than recording a push |
| 1604 | // that could never arrive. |
| 1605 | func TestAPIEmailPushNeedsSomewhereToSend(t *testing.T) { |
| 1606 | fx, _, _, token := emailArmed(t, "code-email-push-nodomain") |
| 1607 | fx.srv.cfg.AppsDomain = "" |
| 1608 | status, body := putRaw(t, emailURL(fx, "titogo")+"/events/push", map[string]any{"path": "/mail/events"}, token) |
| 1609 | if status != http.StatusServiceUnavailable { |
| 1610 | t.Fatalf("with no apps domain = %d %s, want 503", status, body) |
| 1611 | } |
| 1612 | if hook, _ := fx.st.MailEventHookFor(context.Background(), fx.sqid, "titogo"); hook != nil { |
| 1613 | t.Error("a push was recorded with nowhere to send it") |
| 1614 | } |
| 1615 | } |
| 1616 | |
| 1617 | // TestAPIEmailPushOffTakesTheKeyOutEvenWithNoPush: turning the push on |
| 1618 | // delivers the key before it records the push, so a failure between the two |
| 1619 | // leaves a key and no push — and --off must still take the key out. |
| 1620 | func TestAPIEmailPushOffTakesTheKeyOutEvenWithNoPush(t *testing.T) { |
| 1621 | fx, _, _, token := emailArmed(t, "code-email-push-halfway") |
| 1622 | fx.srv.cfg.AppsDomain = "oncarlos.com" |
| 1623 | app := "titogo" |
| 1624 | key, _ := fx.st.MailEventsSigningKey() |
| 1625 | if _, err := appconfig.Apply(context.Background(), fx.rel, fx.sqid, app, |
| 1626 | map[string]appconfig.Var{MailEventsKeyEnv: {Kind: "env", Value: mailEventsPublicKey(key)}}, nil); err != nil { |
| 1627 | t.Fatal(err) |
| 1628 | } |
| 1629 | status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, app)+"/events/push", token, "") |
| 1630 | if status != http.StatusOK { |
| 1631 | t.Fatalf("off: %d %s", status, body) |
| 1632 | } |
| 1633 | if _, ok := appVars(t, fx, app)[MailEventsKeyEnv]; ok { |
| 1634 | t.Errorf("%s was left behind by --off with no push recorded", MailEventsKeyEnv) |
| 1635 | } |
| 1636 | } |
| 1637 | |
| 1638 | // The reply gains a senders array and changes nothing else, so an older CLI |
| 1639 | // reading only the top-level fields keeps working byte for byte. |
| 1640 | func TestEmailReplyCarriesEverySenderAndKeepsItsTopLevelFields(t *testing.T) { |
| 1641 | fx, now := mailFixture(t) |
| 1642 | ctx := context.Background() |
| 1643 | // emailReply reads counts for TODAY (it computes its own day from |
| 1644 | // time.Now(), not from a caller-supplied clock), so the events seeded |
| 1645 | // here have to land on the real day, not mailFixture's fixed 2026-08-19. |
| 1646 | // otherwise MailCounts would legitimately find nothing. |
| 1647 | day := time.Now().UTC().Format("2006-01-02") |
| 1648 | if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil { |
| 1649 | t.Fatal(err) |
| 1650 | } |
| 1651 | if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil { |
| 1652 | t.Fatal(err) |
| 1653 | } |
| 1654 | // A second sender, written straight to its own object the way step 5's |
| 1655 | // instance enable will. |
| 1656 | if _, err := mail.PutSender(ctx, fx.rel, release.OpsAccount, "harbourmail", &mail.Sender{ |
| 1657 | ID: "book.example.com", Scope: mail.ScopeInstance, Source: mail.SourceDomain, Host: "book.example.com", |
| 1658 | Declared: &mail.Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"}, |
| 1659 | Grant: &mail.GrantBlock{Domains: []string{"x.com"}, DailyCap: 1000, Source: "operator", At: now, By: "op"}, |
| 1660 | Provisioned: &mail.Provisioned{IAMUser: "u2", KeyID: "AKIA9", Active: true, At: now}, |
| 1661 | }, ""); err != nil { |
| 1662 | t.Fatal(err) |
| 1663 | } |
| 1664 | |
| 1665 | acct, err := fx.st.AccountBySqid(release.OpsAccount) |
| 1666 | if err != nil || acct == nil { |
| 1667 | t.Fatalf("AccountBySqid = %v %v", acct, err) |
| 1668 | } |
| 1669 | rec, _, err := mail.Get(ctx, fx.rel, release.OpsAccount, "harbourmail") |
| 1670 | if err != nil { |
| 1671 | t.Fatal(err) |
| 1672 | } |
| 1673 | out, err := fx.srv.emailReply(ctx, acct, "harbourmail", rec) |
| 1674 | if err != nil { |
| 1675 | t.Fatal(err) |
| 1676 | } |
| 1677 | |
| 1678 | // Unchanged: the top-level fields still describe the app. |
| 1679 | if out["active"] != true || out["daily_cap"] != 50 { |
| 1680 | t.Fatalf("the top-level fields moved: active=%v daily_cap=%v", out["active"], out["daily_cap"]) |
| 1681 | } |
| 1682 | counts, _ := out["counts"].(map[string]any) |
| 1683 | if counts["sends"] != 2 { |
| 1684 | t.Fatalf("top-level sends = %v, want the app's whole day across both senders", counts["sends"]) |
| 1685 | } |
| 1686 | |
| 1687 | senders, ok := out["senders"].([]map[string]any) |
| 1688 | if !ok || len(senders) != 2 { |
| 1689 | t.Fatalf("senders = %#v, want two", out["senders"]) |
| 1690 | } |
| 1691 | if senders[0]["id"] != mail.SenderAppID || senders[0]["scope"] != mail.ScopeApp { |
| 1692 | t.Fatalf("the app sender is not first: %#v", senders[0]) |
| 1693 | } |
| 1694 | if senders[1]["id"] != "book.example.com" || senders[1]["daily_cap"] != 1000 || senders[1]["host"] != "book.example.com" { |
| 1695 | t.Fatalf("the second sender = %#v", senders[1]) |
| 1696 | } |
| 1697 | first, _ := senders[0]["counts"].(map[string]any) |
| 1698 | second, _ := senders[1]["counts"].(map[string]any) |
| 1699 | if first["sends"] != 1 || second["sends"] != 1 { |
| 1700 | t.Fatalf("per-sender counts = %v / %v, want one send each", first["sends"], second["sends"]) |
| 1701 | } |
| 1702 | } |
| 1703 | |