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
3import (
4 "context"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/carlosframework/platform/internal/mail"
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// The two thresholds SES itself reviews an account on. They are per-tenant
15// here so one tenant's bad list cannot take the estate down (spec §9).
16func TestRatesAreComputedOverSends(t *testing.T) {
17 c := MailCounts{Sends: 1000, Bounces: 60, Complaints: 2}
18 if got := c.BounceRate(); got < 0.059 || got > 0.061 {
19 t.Fatalf("BounceRate = %v, want ~0.06", got)
20 }
21 if got := c.ComplaintRate(); got < 0.0019 || got > 0.0021 {
22 t.Fatalf("ComplaintRate = %v, want ~0.002", got)
23 }
24}
25
26// Zero sends must be zero rates, not a divide-by-zero or a NaN that
27// compares false against every threshold and silently never trips.
28func TestRatesWithNoSendsAreZeroNotNaN(t *testing.T) {
29 c := MailCounts{}
30 if c.BounceRate() != 0 || c.ComplaintRate() != 0 {
31 t.Fatalf("rates on an empty day = %v/%v, want 0/0", c.BounceRate(), c.ComplaintRate())
32 }
33}
34
35// An untiered account defaults to free rather than to unlimited. This is
36// what lets the primitive ship before the billing work on #226 lands
37// (spec §10) — and getting it backwards would hand every unknown account
38// an uncapped estate.
39func TestUntieredAccountsGetTheFreeCap(t *testing.T) {
40 if TierCap("") != TierCap("free") {
41 t.Fatal("an untiered account did not default to the free cap")
42 }
43 if TierCap("free") != 50 {
44 t.Fatalf("free cap = %d, want 50", TierCap("free"))
45 }
46 if TierCap("unknown-tier-name") != TierCap("free") {
47 t.Fatal("an unrecognised tier did not fall back to free")
48 }
49}
50
51// mailFixture seeds a declared, granted, provisioned app so enforce has
52// something to pause. Mirrors storeFixture in apistore_test.go.
53func mailFixture(t *testing.T) (*consoleFixture, time.Time) {
54 t.Helper()
55 fx := newConsoleFixture(t)
56 now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
57 fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1")
58 if _, err := fx.st.CreateAppIn(OpsAccountID, "harbourmail", "paul@keymail.dev", now); err != nil {
59 t.Fatal(err)
60 }
61 if _, err := mail.Declare(context.Background(), fx.rel, release.OpsAccount, "harbourmail",
62 "HARBOURMAIL_SMTP", "noreply@x.com", "owner@example.test", now); err != nil {
63 t.Fatal(err)
64 }
65 if _, err := mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "harbourmail",
66 []string{"x.com"}, 50, "tier:free", "tier", now); err != nil {
67 t.Fatal(err)
68 }
69 // Provision by hand: this test is about the limiter, not the walk.
70 rec, etag, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
71 rec.Provisioned = &mail.Provisioned{IAMUser: "u", KeyID: "AKIA1", Active: true, At: now}
72 if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, "harbourmail", rec, etag); err != nil {
73 t.Fatal(err)
74 }
75 p := fx.srv.cfg.MailProvisioner.(*mail.MemProvisioner)
76 p.EnsureUser(context.Background(), "u", mail.UserPath, []string{"arn:aws:ses:eu-west-1:1:identity/x.com"}, nil, []string{"x.com"})
77 return fx, now
78}
79
80func seedCounts(t *testing.T, fx *consoleFixture, day string, c MailCounts) {
81 t.Helper()
82 ctx := context.Background()
83 for i := 0; i < c.Sends; i++ {
84 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
85 t.Fatal(err)
86 }
87 }
88 for i := 0; i < c.Bounces; i++ {
89 fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventBounce)
90 }
91 for i := 0; i < c.Complaints; i++ {
92 fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventComplaint)
93 }
94}
95
96func TestEnforcePausesOnCapBreach(t *testing.T) {
97 fx, now := mailFixture(t)
98 seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50})
99 paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
100 if err != nil {
101 t.Fatal(err)
102 }
103 if !paused {
104 t.Fatal("cap reached and nothing paused")
105 }
106 if !strings.Contains(reason, "daily cap 50") {
107 t.Fatalf("reason = %q, want it to name the cap", reason)
108 }
109 rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
110 if rec.Provisioned.Active {
111 t.Fatal("record still says active")
112 }
113}
114
115// Below the send floor, a bad ratio is noise. One bounce on a two-message
116// morning is a 50% bounce rate and must NOT pause a healthy tenant.
117func TestEnforceIgnoresRatesBelowTheSendFloor(t *testing.T) {
118 fx, now := mailFixture(t)
119 seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 2, Bounces: 1})
120 paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
121 if err != nil {
122 t.Fatal(err)
123 }
124 if paused {
125 t.Fatal("paused on a 2-message day — the send floor is not being applied")
126 }
127}
128
129// Bounces only — Complaints stays at 0, so this cannot be mistaken for a
130// complaint breach (its reason must name "bounce rate", never "complaint
131// rate"). 6% clears BounceThreshold (5%); Sends=1000 comfortably clears
132// MinSendsForRates.
133func TestEnforcePausesOnBounceRate(t *testing.T) {
134 fx, now := mailFixture(t)
135 // Cap is 50, so raise it first: this test is about the RATE, and a
136 // cap breach would mask it.
137 mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "harbourmail",
138 []string{"x.com"}, 100000, "operator", "owner@example.test", now)
139 seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 1000, Bounces: 60})
140 paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
141 if err != nil {
142 t.Fatal(err)
143 }
144 if !paused {
145 t.Fatal("a 6% bounce rate did not pause")
146 }
147 if !strings.Contains(reason, "bounce rate") {
148 t.Fatalf("reason = %q, want it to name the bounce rate", reason)
149 }
150 if strings.Contains(reason, "complaint rate") {
151 t.Fatalf("reason = %q names the complaint rate on a bounce-only breach", reason)
152 }
153}
154
155// Complaints only — Bounces stays at 0, so this cannot be mistaken for a
156// bounce breach (its reason must name "complaint rate", never "bounce
157// rate"). 0.5% clears ComplaintThreshold (0.1%) while staying far under
158// BounceThreshold (5%), so a threshold swap (BounceThreshold and
159// ComplaintThreshold exchanged) makes this scenario fail to pause at all:
160// the swapped ComplaintThreshold becomes 5%, which 0.5% no longer clears,
161// and BounceRate is 0 so the bounce case never fires either. That is
162// exactly the silent-swap failure mode this test exists to catch.
163func TestEnforcePausesOnComplaintRate(t *testing.T) {
164 fx, now := mailFixture(t)
165 // Cap is 50, so raise it first: this test is about the RATE, and a
166 // cap breach would mask it.
167 mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "harbourmail",
168 []string{"x.com"}, 100000, "operator", "owner@example.test", now)
169 seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 1000, Complaints: 5})
170 paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
171 if err != nil {
172 t.Fatal(err)
173 }
174 if !paused {
175 t.Fatal("a 0.5% complaint rate did not pause")
176 }
177 if !strings.Contains(reason, "complaint rate") {
178 t.Fatalf("reason = %q, want it to name the complaint rate", reason)
179 }
180 if strings.Contains(reason, "bounce rate") {
181 t.Fatalf("reason = %q names the bounce rate on a complaint-only breach", reason)
182 }
183}
184
185// BounceThreshold and ComplaintThreshold are SES's own published
186// account-review thresholds, not values this package chose — pinned here so
187// a later edit that swaps or "tidies" them is caught even if the scenario
188// numbers above are changed too.
189func TestSESThresholdConstants(t *testing.T) {
190 if BounceThreshold != 0.05 {
191 t.Fatalf("BounceThreshold = %v, want 0.05 (SES's published bounce-rate review threshold)", BounceThreshold)
192 }
193 if ComplaintThreshold != 0.001 {
194 t.Fatalf("ComplaintThreshold = %v, want 0.001 (SES's published complaint-rate review threshold)", ComplaintThreshold)
195 }
196}
197
198// A pause must not lift itself. Re-running enforce on a paused app with a
199// clean slate must leave it paused — resume is a decision, not a timer.
200// I3 (review follow-up): a CAP pause is a PER-DAY limit — the counts it was
201// computed from are per-day — so it MUST clear on the UTC day roll, or a
202// free-tier member's ordinary first day over 50 messages stops their email
203// forever, with resume gated behind an operator allowlist that ships empty
204// (CARLOS_MAIL_OPERATORS, fail-closed). This test used to assert the
205// OPPOSITE of what it asserts now — see the note SetPaused's own doc
206// comment carries about the day-roll check this behaviour depends on.
207func TestEnforceCapPauseResumesOnTheNextUTCDay(t *testing.T) {
208 fx, now := mailFixture(t)
209 seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50})
210 if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now); err != nil {
211 t.Fatal(err)
212 }
213 rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
214 if rec.Provisioned.Active {
215 t.Fatal("did not pause on the cap breach")
216 }
217 if rec.Provisioned.PauseKind != mail.PauseKindCap {
218 t.Fatalf("PauseKind = %q, want %q", rec.Provisioned.PauseKind, mail.PauseKindCap)
219 }
220
221 next := now.AddDate(0, 0, 1) // a fresh UTC day, no counts at all
222 paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, next)
223 if err != nil {
224 t.Fatal(err)
225 }
226 if paused {
227 t.Fatal("enforce reports a NEW pause on the day-roll resume — nothing should trip on an empty day")
228 }
229 rec, _, _ = mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
230 if !rec.Provisioned.Active {
231 t.Fatal("a cap pause from yesterday did not resume on the UTC day roll")
232 }
233 if rec.Provisioned.PauseKind != "" {
234 t.Fatalf("PauseKind = %q after the auto-resume, want cleared", rec.Provisioned.PauseKind)
235 }
236
237 entries, err := fx.st.Events(OpsAccountID, "harbourmail", 10)
238 if err != nil {
239 t.Fatal(err)
240 }
241 found := false
242 for _, e := range entries {
243 if strings.Contains(e.Action, "mail.resumed") {
244 found = true
245 }
246 }
247 if !found {
248 t.Fatal("no audit entry for the automatic day-roll resume")
249 }
250}
251
252// The symmetric case: a RATE pause (a bounce/complaint threshold breach)
253// never auto-resumes, day roll or not — SES's account-review thresholds
254// are not a daily allowance, and lifting the brake on a schedule would let
255// an abusive tenant breach it again every day forever.
256func TestEnforceRatePauseNeverAutoResumes(t *testing.T) {
257 fx, now := mailFixture(t)
258 // Cap is 50, so raise it first: this test is about the RATE pause, and
259 // a cap breach would mask which kind actually paused it.
260 if _, err := mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "harbourmail",
261 []string{"x.com"}, 100000, "operator", "owner@example.test", now); err != nil {
262 t.Fatal(err)
263 }
264 seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 1000, Bounces: 60})
265 if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now); err != nil {
266 t.Fatal(err)
267 }
268 rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
269 if rec.Provisioned.Active {
270 t.Fatal("did not pause on the bounce-rate breach")
271 }
272 if rec.Provisioned.PauseKind != mail.PauseKindRate {
273 t.Fatalf("PauseKind = %q, want %q", rec.Provisioned.PauseKind, mail.PauseKindRate)
274 }
275
276 next := now.AddDate(0, 0, 1) // a fresh UTC day
277 paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, next)
278 if err != nil {
279 t.Fatal(err)
280 }
281 if paused {
282 t.Fatal("enforce re-paused an already-paused app — it should have returned early")
283 }
284 rec, _, _ = mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
285 if rec.Provisioned.Active {
286 t.Fatal("a rate pause auto-resumed on the UTC day roll — it must not")
287 }
288 if rec.Provisioned.PauseKind != mail.PauseKindRate {
289 t.Fatalf("PauseKind = %q after a no-op day roll, want it to survive as %q", rec.Provisioned.PauseKind, mail.PauseKindRate)
290 }
291}
292
293// The other half of "nothing auto-resumes": TestEnforceNeverAutoResumes above
294// re-checks on a day with zero counts, which an early-return guard and a
295// mutant that DROPS that guard both pass — a day with nothing to trip trips
296// nothing either way, so that test alone does not prove the guard exists.
297// This test keeps the cap breached (even further breached) on the SAME day
298// and re-runs enforce: only the early-return guard stops a second pass, a
299// second SetPaused call and a second audit line. (Mutation testing caught
300// this gap — see the task report.)
301func TestEnforceAlreadyPausedDoesNotReprocess(t *testing.T) {
302 fx, now := mailFixture(t)
303 day := now.UTC().Format("2006-01-02")
304 seedCounts(t, fx, day, MailCounts{Sends: 50})
305 if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now); err != nil {
306 t.Fatal(err)
307 }
308
309 // More sends land on the SAME day, well past the cap — a re-evaluating
310 // enforce would trip again.
311 seedCounts(t, fx, day, MailCounts{Sends: 25})
312 paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
313 if err != nil {
314 t.Fatal(err)
315 }
316 if paused {
317 t.Fatalf("enforce re-processed an already-paused app (reason %q) — the early-return guard is not applied", reason)
318 }
319
320 entries, err := fx.st.Events(OpsAccountID, "harbourmail", 10)
321 if err != nil {
322 t.Fatal(err)
323 }
324 pauses := 0
325 for _, e := range entries {
326 if strings.Contains(e.Action, "mail.paused") {
327 pauses++
328 }
329 }
330 if pauses != 1 {
331 t.Fatalf("got %d mail.paused audit entries, want exactly 1 (a second pass must not re-audit)", pauses)
332 }
333}
334
335// A pause nobody can see presents to the member as "email stopped working".
336// The real audit reader is Store.Events(accountID, app, limit) — see F7:
337// Store has no AuditEntries method, and s.audit(acct *Account, app, addr,
338// action, detail string) is what production code calls.
339func TestEnforceRecordsTheReasonInTheAuditLog(t *testing.T) {
340 fx, now := mailFixture(t)
341 seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50})
342 fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
343
344 entries, err := fx.st.Events(OpsAccountID, "harbourmail", 10)
345 if err != nil {
346 t.Fatal(err)
347 }
348 for _, e := range entries {
349 if strings.Contains(e.Action, "mail.paused") && strings.Contains(e.Detail, "daily cap 50") {
350 return
351 }
352 }
353 t.Fatalf("no audit entry naming the pause reason: %+v", entries)
354}
355
356// pauseDirectly writes rec.Provisioned's Active/PauseKind/At by hand — the
357// way SetPaused already would, without needing to breach a threshold
358// first. Used by the resumeStaleCapPause tests below, which pin the shared
359// day-roll function's contract directly (R2, review follow-up) rather than
360// only through enforceMailLimits' caller.
361func pauseDirectly(t *testing.T, fx *consoleFixture, app, kind string, at time.Time) {
362 t.Helper()
363 rec, etag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, app)
364 if err != nil {
365 t.Fatal(err)
366 }
367 rec.Provisioned.Active = false
368 rec.Provisioned.PauseKind = kind
369 rec.Provisioned.At = at
370 if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, etag); err != nil {
371 t.Fatal(err)
372 }
373}
374
375// R2: resumeStaleCapPause is the ONE function both enforceMailLimits (the
376// SES event path) and handleAPIEmailStatus (the status READ path) share,
377// so these four cases pin its contract directly.
378
379func TestResumeStaleCapPauseClearsACapPauseFromYesterday(t *testing.T) {
380 fx, now := mailFixture(t)
381 pauseDirectly(t, fx, "harbourmail", mail.PauseKindCap, now.AddDate(0, 0, -1))
382 sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
383 if err != nil {
384 t.Fatal(err)
385 }
386 got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
387 if err != nil {
388 t.Fatal(err)
389 }
390 if !resumed {
391 t.Fatal("did not resume a cap pause applied on an earlier UTC day")
392 }
393 if !got.Provisioned.Active {
394 t.Fatal("resumed record still reports Active == false")
395 }
396 if got.Provisioned.PauseKind != "" {
397 t.Fatalf("PauseKind = %q after resume, want cleared", got.Provisioned.PauseKind)
398 }
399}
400
401func TestResumeStaleCapPauseLeavesATodayCapPauseAlone(t *testing.T) {
402 fx, now := mailFixture(t)
403 pauseDirectly(t, fx, "harbourmail", mail.PauseKindCap, now) // paused earlier the SAME UTC day
404 sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
405 if err != nil {
406 t.Fatal(err)
407 }
408 got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
409 if err != nil {
410 t.Fatal(err)
411 }
412 if resumed {
413 t.Fatal("resumed a cap pause applied the SAME UTC day — that makes the cap meaningless")
414 }
415 if got.Provisioned.Active {
416 t.Fatal("same-day cap pause was lifted")
417 }
418}
419
420func TestResumeStaleCapPauseNeverTouchesARatePause(t *testing.T) {
421 fx, now := mailFixture(t)
422 pauseDirectly(t, fx, "harbourmail", mail.PauseKindRate, now.AddDate(0, 0, -1))
423 sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
424 if err != nil {
425 t.Fatal(err)
426 }
427 got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
428 if err != nil {
429 t.Fatal(err)
430 }
431 if resumed {
432 t.Fatal("resumed a RATE pause — a rate pause must never auto-resume, day roll or not")
433 }
434 if got.Provisioned.Active {
435 t.Fatal("a rate pause from yesterday was lifted")
436 }
437 if got.Provisioned.PauseKind != mail.PauseKindRate {
438 t.Fatalf("PauseKind = %q, want it to survive as %q", got.Provisioned.PauseKind, mail.PauseKindRate)
439 }
440}
441
442func TestResumeStaleCapPauseNeverTouchesAKindlessPause(t *testing.T) {
443 fx, now := mailFixture(t)
444 pauseDirectly(t, fx, "harbourmail", "", now.AddDate(0, 0, -1))
445 sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
446 if err != nil {
447 t.Fatal(err)
448 }
449 got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
450 if err != nil {
451 t.Fatal(err)
452 }
453 if resumed {
454 t.Fatal("resumed a kind-less pause (pre-existing record or manual pause) — fail safe means these never auto-resume")
455 }
456 if got.Provisioned.Active {
457 t.Fatal("a kind-less pause from yesterday was lifted")
458 }
459}
460
461// SQLite cannot widen a primary key, so per-sender counts are a new table,
462// backfilled once from mail_counts with sender = 'app'. The backfill runs at
463// most once for the life of the database: a second migrate must not
464// resurrect rows a purge or a rename has since removed.
465func TestMailSenderCountsAreBackfilledExactlyOnce(t *testing.T) {
466 dir := t.TempDir()
467 path := filepath.Join(dir, "console.db")
468 st, err := Open(path)
469 if err != nil {
470 t.Fatal(err)
471 }
472 if _, err := st.db.Exec(`INSERT INTO mail_counts (account, app, day, sends, bounces, complaints, rejects, deliveries)
473VALUES ('bag', 'harbourmail', '2026-09-14', 7, 1, 0, 0, 6)`); err != nil {
474 t.Fatal(err)
475 }
476 // Force the backfill to run again by winding the marker back, which is
477 // what a console rolled back and forward would look like.
478 if _, err := st.db.Exec(`PRAGMA user_version = 0`); err != nil {
479 t.Fatal(err)
480 }
481 if err := st.migrate(); err != nil {
482 t.Fatal(err)
483 }
484 var sends, deliveries int
485 if err := st.db.QueryRow(`SELECT sends, deliveries FROM mail_sender_counts
486WHERE account = 'bag' AND app = 'harbourmail' AND sender = 'app' AND day = '2026-09-14'`).Scan(&sends, &deliveries); err != nil {
487 t.Fatalf("the backfill did not land: %v", err)
488 }
489 if sends != 7 || deliveries != 6 {
490 t.Fatalf("backfilled %d sends and %d deliveries, want 7 and 6", sends, deliveries)
491 }
492
493 // A row removed after the backfill stays removed across a restart.
494 if _, err := st.db.Exec(`DELETE FROM mail_sender_counts WHERE account = 'bag'`); err != nil {
495 t.Fatal(err)
496 }
497 if err := st.migrate(); err != nil {
498 t.Fatal(err)
499 }
500 var n int
501 if err := st.db.QueryRow(`SELECT count(*) FROM mail_sender_counts WHERE account = 'bag'`).Scan(&n); err != nil {
502 t.Fatal(err)
503 }
504 if n != 0 {
505 t.Fatalf("the backfill resurrected %d deleted rows", n)
506 }
507
508 // The legacy table is dropped only when an operator asks, a release later.
509 if err := st.DropLegacyMailCounts(false); err != nil {
510 t.Fatal(err)
511 }
512 if err := st.db.QueryRow(`SELECT count(*) FROM mail_counts`).Scan(&n); err != nil {
513 t.Fatalf("mail_counts was dropped while the flag was off: %v", err)
514 }
515 if err := st.DropLegacyMailCounts(true); err != nil {
516 t.Fatal(err)
517 }
518 if err := st.db.QueryRow(`SELECT count(*) FROM mail_counts`).Scan(&n); err == nil {
519 t.Fatal("mail_counts survived its own drop")
520 }
521 if err := st.Close(); err != nil {
522 t.Fatal(err)
523 }
524}
525
526// Counts are per sender from here on. An empty sender reads the app's whole
527// day across every sender, which is what the status reply's top-level
528// figures and the roll-up both need.
529func TestMailCountsAreKeptAndReadPerSender(t *testing.T) {
530 fx := newConsoleFixture(t)
531 ctx := context.Background()
532 const day = "2026-09-15"
533 for i := 0; i < 3; i++ {
534 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
535 t.Fatal(err)
536 }
537 }
538 for i := 0; i < 2; i++ {
539 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
540 t.Fatal(err)
541 }
542 }
543 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventBounce); err != nil {
544 t.Fatal(err)
545 }
546 app, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day)
547 if err != nil {
548 t.Fatal(err)
549 }
550 if app.Sends != 3 || app.Bounces != 0 {
551 t.Fatalf("app sender = %+v, want 3 sends and no bounces", app)
552 }
553 one, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "book.example.com", day)
554 if err != nil {
555 t.Fatal(err)
556 }
557 if one.Sends != 2 || one.Bounces != 1 {
558 t.Fatalf("instance sender = %+v, want 2 sends and 1 bounce", one)
559 }
560 all, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "", day)
561 if err != nil {
562 t.Fatal(err)
563 }
564 if all.Sends != 5 || all.Bounces != 1 {
565 t.Fatalf("every sender = %+v, want 5 sends and 1 bounce", all)
566 }
567 // A day with no rows is a legitimate zero, not an error and not an
568 // ErrNoRows the caller has to know about.
569 quiet, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "", "2026-01-01")
570 if err != nil || quiet != (MailCounts{}) {
571 t.Fatalf("a quiet day = %+v %v", quiet, err)
572 }
573}
574
575// The cap applies per sender, so one sender's breach pauses that sender and
576// leaves its siblings sending. The counts are chosen to discriminate a
577// summed read from a per-sender one: 25 (app) + 40 (sibling) = 65, over the
578// cap of 50, so a summed MailCounts read would trip here even though
579// neither sender has yet reached 50 on its own.
580func TestTheCapIsEnforcedPerSender(t *testing.T) {
581 fx, now := mailFixture(t)
582 ctx := context.Background()
583 day := now.UTC().Format("2006-01-02")
584
585 // A provisioned, active sibling sender — checked below so a pause is
586 // proven to leave it alone, not just left unread.
587 sibling := &mail.Sender{
588 ID: "book.example.com", Scope: mail.ScopeInstance, Source: mail.SourceDomain,
589 Provisioned: &mail.Provisioned{IAMUser: "book-u", KeyID: "AKIA2", Active: true, At: now},
590 }
591 if _, err := mail.PutSender(ctx, fx.rel, release.OpsAccount, "harbourmail", sibling, ""); err != nil {
592 t.Fatal(err)
593 }
594
595 for i := 0; i < 25; i++ {
596 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
597 t.Fatal(err)
598 }
599 }
600 for i := 0; i < 40; i++ {
601 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
602 t.Fatal(err)
603 }
604 }
605 paused, _, err := fx.srv.enforceMailLimits(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, now)
606 if err != nil {
607 t.Fatal(err)
608 }
609 if paused {
610 t.Fatal("the app sender paused on 25 of its own sends plus a sibling's 40 — the cap is being read summed across senders")
611 }
612
613 // Push the app sender's OWN count past its own cap of 50. The sibling's
614 // count is untouched at 40, still under its own cap.
615 for i := 0; i < 26; i++ {
616 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
617 t.Fatal(err)
618 }
619 }
620 paused, reason, err := fx.srv.enforceMailLimits(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, now)
621 if err != nil {
622 t.Fatal(err)
623 }
624 if !paused || !strings.Contains(reason, "daily cap 50") {
625 t.Fatalf("app sender = %v %q, want a cap pause", paused, reason)
626 }
627 sn, _, err := mail.GetSender(ctx, fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
628 if err != nil || sn.Provisioned.Active || sn.Provisioned.PauseKind != mail.PauseKindCap {
629 t.Fatalf("the app sender's record = %+v %v", sn.Provisioned, err)
630 }
631 // The pause must land on the sender that breached, never a sibling that
632 // did not: the sibling is still under its own cap and must still be
633 // Active.
634 sib, _, err := mail.GetSender(ctx, fx.rel, release.OpsAccount, "harbourmail", "book.example.com")
635 if err != nil || sib == nil || !sib.Provisioned.Active {
636 t.Fatalf("the sibling sender's record = %+v %v, want it left Active", sib, err)
637 }
638
639 // The day roll clears a cap pause, still, and only a cap pause.
640 fresh, resumed, err := fx.srv.resumeStaleCapPause(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now.AddDate(0, 0, 1))
641 if err != nil || !resumed || fresh == nil || !fresh.Provisioned.Active {
642 t.Fatalf("the day roll did not clear the cap pause: %+v %v %v", fresh, resumed, err)
643 }
644 // The sibling is still untouched by the day roll on the app sender's
645 // pause — resumeStaleCapPause was given the app sender, not the
646 // sibling, so the sibling's own state must not have moved.
647 sib, _, err = mail.GetSender(ctx, fx.rel, release.OpsAccount, "harbourmail", "book.example.com")
648 if err != nil || sib == nil || !sib.Provisioned.Active {
649 t.Fatalf("the sibling sender's record = %+v %v after the day roll, want it left Active", sib, err)
650 }
651}
652
653// The pause targets the sender that actually breached its cap, never a
654// hard-coded stand-in for it: if enforceMailLimits paused the app sender
655// regardless of which sender was asked for, a sibling's own breach would
656// leave the sibling sending and silently pause the app sender instead.
657func TestTheCapPauseTargetsTheBreachingSenderNotTheAppSender(t *testing.T) {
658 fx, now := mailFixture(t)
659 ctx := context.Background()
660 day := now.UTC().Format("2006-01-02")
661
662 sibling := &mail.Sender{
663 ID: "book.example.com", Scope: mail.ScopeInstance, Source: mail.SourceDomain,
664 Provisioned: &mail.Provisioned{IAMUser: "book-u", KeyID: "AKIA2", Active: true, At: now},
665 }
666 if _, err := mail.PutSender(ctx, fx.rel, release.OpsAccount, "harbourmail", sibling, ""); err != nil {
667 t.Fatal(err)
668 }
669
670 // Only the SIBLING breaches its cap; the app sender sends nothing.
671 for i := 0; i < 50; i++ {
672 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
673 t.Fatal(err)
674 }
675 }
676 paused, reason, err := fx.srv.enforceMailLimits(ctx, release.OpsAccount, "harbourmail", "book.example.com", now)
677 if err != nil {
678 t.Fatal(err)
679 }
680 if !paused || !strings.Contains(reason, "daily cap 50") {
681 t.Fatalf("sibling sender = %v %q, want a cap pause", paused, reason)
682 }
683 sib, _, err := mail.GetSender(ctx, fx.rel, release.OpsAccount, "harbourmail", "book.example.com")
684 if err != nil || sib.Provisioned.Active || sib.Provisioned.PauseKind != mail.PauseKindCap {
685 t.Fatalf("the sibling sender's record = %+v %v, want it paused", sib.Provisioned, err)
686 }
687 // The app sender never breached anything and must still be Active — a
688 // pause hard-coded to the app sender would show up here instead.
689 app, _, err := mail.GetSender(ctx, fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
690 if err != nil || app == nil || !app.Provisioned.Active {
691 t.Fatalf("the app sender's record = %+v %v, want it left Active", app, err)
692 }
693}
694
695// resumeStaleCapPause must never resume a PerInstance sender's stale cap
696// pause: resuming reactivates every key it touches, including the app
697// credential every instance still holds, which is exactly what per-instance
698// mode exists to prevent (design section 6.3). Nothing sets PerInstance in
699// this release; this pins the guard directly so it cannot regress silently.
700func TestResumeStaleCapPauseNeverTouchesAPerInstanceSender(t *testing.T) {
701 fx, now := mailFixture(t)
702 pauseDirectly(t, fx, "harbourmail", mail.PauseKindCap, now.AddDate(0, 0, -1))
703 sn, etag, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
704 if err != nil {
705 t.Fatal(err)
706 }
707 sn.PerInstance = true
708 if _, err := mail.PutSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", sn, etag); err != nil {
709 t.Fatal(err)
710 }
711 got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
712 if err != nil {
713 t.Fatal(err)
714 }
715 if resumed {
716 t.Fatal("resumed a PerInstance sender's stale cap pause — re-arms a credential every instance still holds")
717 }
718 if got.Provisioned.Active {
719 t.Fatal("a PerInstance sender's cap pause from yesterday was lifted")
720 }
721}
722
723// mailAuditSubject is the naming rule directly: every app has exactly one
724// sender in this release (the app sender), so naming it would add "/app" to
725// a member-facing audit line for no information a member could use — and
726// that "/app" form never went through this repo's copy-review gate. Only a
727// sender that is NOT the app sender earns the "/sender" suffix.
728func TestMailAuditSubjectNamesOnlyANonAppSender(t *testing.T) {
729 if got := mailAuditSubject("harbourmail", mail.SenderAppID); got != "harbourmail" {
730 t.Fatalf("mailAuditSubject(app sender) = %q, want the bare app name", got)
731 }
732 if got := mailAuditSubject("harbourmail", "book.example.com"); got != "harbourmail/book.example.com" {
733 t.Fatalf("mailAuditSubject(non-app sender) = %q, want app/sender", got)
734 }
735}
736
737// The app sender's pause line must be byte-identical to what every live app
738// already prints today (pre-dating per-sender senders): "harbourmail: daily
739// cap 50 reached (50 sent)". Naming the sender here would be a member-facing
740// copy change this repo's copy-review gate never saw.
741func TestEnforceAuditDetailIsByteIdenticalForTheAppSender(t *testing.T) {
742 fx, now := mailFixture(t)
743 seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50})
744 if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now); err != nil {
745 t.Fatal(err)
746 }
747
748 entries, err := fx.st.Events(OpsAccountID, "harbourmail", 10)
749 if err != nil {
750 t.Fatal(err)
751 }
752 want := "harbourmail: daily cap 50 reached (50 sent)"
753 for _, e := range entries {
754 if e.Action == "mail.paused" {
755 if e.Detail != want {
756 t.Fatalf("pause detail = %q, want byte-identical %q", e.Detail, want)
757 }
758 return
759 }
760 }
761 t.Fatalf("no mail.paused audit entry found: %+v", entries)
762}
763
764// A non-app sender's pause line DOES carry the sender, since "harbourmail"
765// alone would not tell a member which of two senders paused.
766func TestEnforceAuditDetailNamesANonAppSender(t *testing.T) {
767 fx, now := mailFixture(t)
768 ctx := context.Background()
769 day := now.UTC().Format("2006-01-02")
770
771 sibling := &mail.Sender{
772 ID: "book.example.com", Scope: mail.ScopeInstance, Source: mail.SourceDomain,
773 Provisioned: &mail.Provisioned{IAMUser: "book-u", KeyID: "AKIA2", Active: true, At: now},
774 }
775 if _, err := mail.PutSender(ctx, fx.rel, release.OpsAccount, "harbourmail", sibling, ""); err != nil {
776 t.Fatal(err)
777 }
778 for i := 0; i < 50; i++ {
779 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
780 t.Fatal(err)
781 }
782 }
783 if _, _, err := fx.srv.enforceMailLimits(ctx, release.OpsAccount, "harbourmail", "book.example.com", now); err != nil {
784 t.Fatal(err)
785 }
786
787 entries, err := fx.st.Events(OpsAccountID, "harbourmail", 10)
788 if err != nil {
789 t.Fatal(err)
790 }
791 want := "harbourmail/book.example.com: daily cap 50 reached (50 sent)"
792 for _, e := range entries {
793 if e.Action == "mail.paused" {
794 if e.Detail != want {
795 t.Fatalf("pause detail = %q, want %q", e.Detail, want)
796 }
797 return
798 }
799 }
800 t.Fatalf("no mail.paused audit entry found: %+v", entries)
801}
802
803// The resume line follows the same rule: byte-identical for the app sender.
804func TestResumeAuditDetailIsByteIdenticalForTheAppSender(t *testing.T) {
805 fx, now := mailFixture(t)
806 pauseDirectly(t, fx, "harbourmail", mail.PauseKindCap, now.AddDate(0, 0, -1))
807 sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
808 if err != nil {
809 t.Fatal(err)
810 }
811 if _, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now); err != nil || !resumed {
812 t.Fatalf("resume = %v %v, want it to resume", resumed, err)
813 }
814
815 entries, err := fx.st.Events(OpsAccountID, "harbourmail", 10)
816 if err != nil {
817 t.Fatal(err)
818 }
819 want := "harbourmail: daily cap pause cleared on the UTC day roll"
820 for _, e := range entries {
821 if e.Action == "mail.resumed" {
822 if e.Detail != want {
823 t.Fatalf("resume detail = %q, want byte-identical %q", e.Detail, want)
824 }
825 return
826 }
827 }
828 t.Fatalf("no mail.resumed audit entry found: %+v", entries)
829}
830