carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
13import (
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
28func 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
41func 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.
50func 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.
74func 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
82func 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
90func 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
99func 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
107func 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.
119func 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.
131func 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.
142func 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.
159func 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.
232func 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.
299func 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.
326func 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.
358func 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.
377func 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.
396func 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.
418func 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.
454func 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.
491func 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.
507func 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.
529func 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.
570func 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.
603func 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.
618func 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.
673func 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.
698func 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.
728func 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).
753func 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.
767func 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.
777func 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.
802func 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.
814func 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.
828func 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.
860func 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.
891func 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.
921func 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.
939func 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.
961func 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.
988func 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.
1026func 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.
1042func 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.
1084func 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.
1133func 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.
1164func 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.
1177func 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.
1206func 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.
1229func 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.
1253func 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.
1279func 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.
1314func 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.
1352func 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.
1369func 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.
1415func 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.
1463func 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.
1488func 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.
1556func 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.
1605func 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.
1620func 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.
1640func 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