carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package mail
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/appconfig"
13 "github.com/carlosframework/platform/internal/dnsrec"
14 "github.com/carlosframework/platform/internal/release"
15)
16
17// Rotation leaves the OLD key ACTIVE. Adoption swaps a symlink; it does not
18// restart processes, so an instance may still be using the old credential
19// for minutes after delivery. Killing it at rotation time is an outage.
20func TestRotateKeepsTheOldKeyLive(t *testing.T) {
21 ctx, s, p, pub, now := enableFixture(t)
22 first, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
23 if err != nil {
24 t.Fatalf("Enable: %v", err)
25 }
26 old := first.Provisioned.KeyID
27
28 rec, err := Rotate(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
29 if err != nil {
30 t.Fatalf("Rotate: %v", err)
31 }
32 if rec.Provisioned.KeyID == old {
33 t.Fatal("Rotate did not mint a new key")
34 }
35 if rec.Provisioned.PrevKeyID != old {
36 t.Fatalf("PrevKeyID = %q, want %q", rec.Provisioned.PrevKeyID, old)
37 }
38 keys := p.Users[UserName("bag", "harbourmail")].Keys
39 if _, ok := keys[old]; !ok {
40 t.Fatal("the old key was deleted at rotation — instances still using it would fail")
41 }
42 if !keys[old].Active {
43 t.Fatal("the old key was deactivated at rotation")
44 }
45}
46
47// C1: Rotate must not silently lift a standing pause at IAM. MintKey always
48// returns an ACTIVE key regardless of the record's own Active flag; without
49// the enforcement point, a paused app's record keeps saying paused (and the
50// console and audit log keep agreeing) while the freshly minted key is
51// live — an ordinary member `carlos email rotate` would defeat an abuse
52// pause.
53func TestRotateDoesNotLiftAPauseAtIAM(t *testing.T) {
54 ctx, s, p, pub, now := enableFixture(t)
55 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
56 t.Fatalf("Enable: %v", err)
57 }
58 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, "", now); err != nil {
59 t.Fatalf("SetPaused(true): %v", err)
60 }
61
62 rec, err := Rotate(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
63 if err != nil {
64 t.Fatalf("Rotate: %v", err)
65 }
66 if rec.Provisioned.Active {
67 t.Fatal("rotate flipped the record back to active")
68 }
69 user := UserName("bag", "harbourmail")
70 k, ok := p.Users[user].Keys[rec.Provisioned.KeyID]
71 if !ok {
72 t.Fatalf("minted key %s does not exist at all", rec.Provisioned.KeyID)
73 }
74 if k.Active {
75 t.Fatal("rotate minted a LIVE key for a paused app — the pause was silently lifted at IAM")
76 }
77}
78
79// Finish is the only walk that destroys a credential an app might still be
80// using, so it is explicit and separate.
81func TestRotateFinishClosesTheWindow(t *testing.T) {
82 ctx, s, p, pub, now := enableFixture(t)
83 Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
84 rec, _ := Rotate(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
85 old := rec.Provisioned.PrevKeyID
86
87 done, err := RotateFinish(ctx, s, p, "bag", "harbourmail")
88 if err != nil {
89 t.Fatalf("RotateFinish: %v", err)
90 }
91 if done.Provisioned.PrevKeyID != "" {
92 t.Fatal("PrevKeyID survived the finish")
93 }
94 if _, ok := p.Users[UserName("bag", "harbourmail")].Keys[old]; ok {
95 t.Fatal("the old key survived the finish")
96 }
97}
98
99// I1: Enable's repair re-walk has the identical defect as Rotate. Task 12
100// made Enable carry Active forward in the RECORD; that alone is half the
101// feature — a repair re-walk still reaps the paused key and mints a fresh
102// one, and MintKey always hands back a live key.
103func TestEnableRepairDoesNotLiftAPauseAtIAM(t *testing.T) {
104 ctx, s, p, pub, now := enableFixture(t)
105 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
106 t.Fatalf("first Enable: %v", err)
107 }
108 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, "", now); err != nil {
109 t.Fatalf("SetPaused(true): %v", err)
110 }
111 // Clear a delivered var so the idempotence short-circuit cannot fire —
112 // this forces Enable to actually re-walk (reap + re-mint) rather than
113 // return early.
114 if _, err := appconfig.Unset(ctx, s, "bag", "harbourmail", []string{"HARBOURMAIL_SMTP_HOST"}); err != nil {
115 t.Fatalf("Unset: %v", err)
116 }
117
118 rec, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
119 if err != nil {
120 t.Fatalf("repair Enable: %v", err)
121 }
122 if rec.Provisioned.Active {
123 t.Fatal("repair re-walk flipped the record back to active")
124 }
125 user := UserName("bag", "harbourmail")
126 k, ok := p.Users[user].Keys[rec.Provisioned.KeyID]
127 if !ok {
128 t.Fatalf("minted key %s does not exist at all", rec.Provisioned.KeyID)
129 }
130 if k.Active {
131 t.Fatal("repair re-walk minted a LIVE key for a paused app — the pause was silently lifted at IAM")
132 }
133}
134
135// The pause is SetKeyActive, and it must be reversible with no policy
136// rewrite — a policy rewrite would have to be undone correctly to resume
137// and could half-fail into a broken grant.
138func TestPauseAndResumeToggleTheKeyNotThePolicy(t *testing.T) {
139 ctx, s, p, pub, now := enableFixture(t)
140 rec, _ := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
141 user := UserName("bag", "harbourmail")
142 policyBefore := p.Users[user].Policy
143
144 paused, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, "", now)
145 if err != nil {
146 t.Fatalf("SetPaused(true): %v", err)
147 }
148 if paused.Provisioned.Active {
149 t.Fatal("record still says active after a pause")
150 }
151 if p.Users[user].Keys[rec.Provisioned.KeyID].Active {
152 t.Fatal("the key is still active after a pause")
153 }
154 if p.Users[user].Policy != policyBefore {
155 t.Fatal("the pause rewrote the policy — it must only toggle the key")
156 }
157
158 resumed, err := SetPaused(ctx, s, p, "bag", "harbourmail", false, "", now)
159 if err != nil {
160 t.Fatalf("SetPaused(false): %v", err)
161 }
162 if !resumed.Provisioned.Active || !p.Users[user].Keys[rec.Provisioned.KeyID].Active {
163 t.Fatal("resume did not reactivate")
164 }
165}
166
167// SetPaused must toggle BOTH keys — an app mid-rotation still holds the
168// previous credential, so pausing only the current one leaves it sending.
169func TestPauseTogglesBothKeysDuringARotationOverlap(t *testing.T) {
170 ctx, s, p, pub, now := enableFixture(t)
171 Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
172 rec, err := Rotate(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
173 if err != nil {
174 t.Fatalf("Rotate: %v", err)
175 }
176 user := UserName("bag", "harbourmail")
177 current := rec.Provisioned.KeyID
178 prev := rec.Provisioned.PrevKeyID
179 if prev == "" {
180 t.Fatal("fixture broken: expected a PrevKeyID after Rotate")
181 }
182
183 paused, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, "", now)
184 if err != nil {
185 t.Fatalf("SetPaused(true): %v", err)
186 }
187 if paused.Provisioned.Active {
188 t.Fatal("record still says active after a pause")
189 }
190 if p.Users[user].Keys[current].Active {
191 t.Fatal("the current key is still active after a pause")
192 }
193 if p.Users[user].Keys[prev].Active {
194 t.Fatal("the previous key (mid-rotation overlap) is still active after a pause — it could still be sending")
195 }
196}
197
198// I2: SetPaused must reach standalone principals too. Each is its own IAM
199// user, so there is no two-key ceiling interaction to worry about; skipping
200// them leaves a tenant paused for abuse still sending through, say, a
201// laptop credential on the very domain the pause was meant to protect.
202func TestPauseReachesStandaloneCredentials(t *testing.T) {
203 ctx, s, p, pub, now := enableFixture(t)
204 Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
205 if _, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail",
206 "laptop", "owner@example.test", now); err != nil {
207 t.Fatalf("CreateStandalone: %v", err)
208 }
209 standaloneUser := StandaloneUserName("bag", "harbourmail", "laptop")
210
211 rec, _, err := Get(ctx, s, "bag", "harbourmail")
212 if err != nil {
213 t.Fatalf("Get: %v", err)
214 }
215 var keyID string
216 for _, sa := range rec.Standalone {
217 if sa.Name == "laptop" {
218 keyID = sa.KeyID
219 }
220 }
221 if keyID == "" {
222 t.Fatal("fixture broken: standalone entry missing from record")
223 }
224
225 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, "", now); err != nil {
226 t.Fatalf("SetPaused(true): %v", err)
227 }
228 su, ok := p.Users[standaloneUser]
229 if !ok {
230 t.Fatalf("standalone user %s does not exist at all", standaloneUser)
231 }
232 k, ok := su.Keys[keyID]
233 if !ok {
234 t.Fatalf("standalone key %s does not exist at all", keyID)
235 }
236 if k.Active {
237 t.Fatal("pause did not reach the standalone credential — it can still send")
238 }
239
240 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", false, "", now); err != nil {
241 t.Fatalf("SetPaused(false): %v", err)
242 }
243 su, ok = p.Users[standaloneUser]
244 if !ok {
245 t.Fatalf("resume left the standalone user %s gone entirely", standaloneUser)
246 }
247 k, ok = su.Keys[keyID]
248 if !ok {
249 t.Fatalf("resume left the standalone key %s gone entirely", keyID)
250 }
251 if !k.Active {
252 t.Fatal("resume did not reach the standalone credential")
253 }
254}
255
256// Round 2, item 1: CreateStandalone is a THIRD mint path and must go
257// through the same enforcement point as Enable and Rotate. Without it, a
258// member verb (`carlos email credentials create`) could hand a paused
259// tenant a live sending credential on the same verified domain the pause
260// was meant to protect — exactly the C1 shape, on a path C1's original fix
261// did not cover.
262func TestCreateStandaloneDoesNotBypassAPause(t *testing.T) {
263 ctx, s, p, pub, now := enableFixture(t)
264 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
265 t.Fatalf("Enable: %v", err)
266 }
267 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, "", now); err != nil {
268 t.Fatalf("SetPaused(true): %v", err)
269 }
270
271 creds, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail",
272 "laptop", "owner@example.test", now)
273 if err != nil {
274 t.Fatalf("CreateStandalone: %v", err)
275 }
276 standaloneUser := StandaloneUserName("bag", "harbourmail", "laptop")
277 su, ok := p.Users[standaloneUser]
278 if !ok {
279 t.Fatalf("standalone user %s does not exist at all", standaloneUser)
280 }
281 k, ok := su.Keys[creds.Username]
282 if !ok {
283 t.Fatalf("minted standalone key %s does not exist at all", creds.Username)
284 }
285 if k.Active {
286 t.Fatal("CreateStandalone minted a LIVE key for a paused app — the pause was bypassed")
287 }
288}
289
290// A re-mint for an existing standalone name while paused must not reactivate
291// it either, and a resume must reach a standalone credential created while
292// paused.
293func TestCreateStandaloneReMintWhilePausedStaysInactiveAndResumeReactivates(t *testing.T) {
294 ctx, s, p, pub, now := enableFixture(t)
295 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
296 t.Fatalf("Enable: %v", err)
297 }
298 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, "", now); err != nil {
299 t.Fatalf("SetPaused(true): %v", err)
300 }
301 if _, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail",
302 "laptop", "owner@example.test", now); err != nil {
303 t.Fatalf("CreateStandalone: %v", err)
304 }
305 // Re-mint the same name while still paused.
306 creds, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail",
307 "laptop", "owner@example.test", now)
308 if err != nil {
309 t.Fatalf("CreateStandalone (re-mint): %v", err)
310 }
311 standaloneUser := StandaloneUserName("bag", "harbourmail", "laptop")
312 su, ok := p.Users[standaloneUser]
313 if !ok {
314 t.Fatalf("standalone user %s does not exist at all", standaloneUser)
315 }
316 k, ok := su.Keys[creds.Username]
317 if !ok {
318 t.Fatalf("minted standalone key %s does not exist at all", creds.Username)
319 }
320 if k.Active {
321 t.Fatal("a re-mint while paused produced a LIVE key")
322 }
323
324 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", false, "", now); err != nil {
325 t.Fatalf("SetPaused(false): %v", err)
326 }
327 su, ok = p.Users[standaloneUser]
328 if !ok {
329 t.Fatalf("resume left the standalone user %s gone entirely", standaloneUser)
330 }
331 k, ok = su.Keys[creds.Username]
332 if !ok {
333 t.Fatalf("resume left the standalone key %s gone entirely", creds.Username)
334 }
335 if !k.Active {
336 t.Fatal("resume did not reach a standalone credential created while paused")
337 }
338}
339
340// A standalone credential lives on its OWN IAM user, because the delivered
341// user's two key slots are spent on the delivered key and the rotation
342// overlap. Sharing would work until the first rotation and then break it.
343func TestStandaloneGetsItsOwnUserAndDoesNotDisturbDelivery(t *testing.T) {
344 ctx, s, p, pub, now := enableFixture(t)
345 rec, _ := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
346 delivered := rec.Provisioned.KeyID
347
348 creds, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail", "laptop", "owner@example.test", now)
349 if err != nil {
350 t.Fatalf("CreateStandalone: %v", err)
351 }
352 if creds.Password == "" || creds.Username == "" {
353 t.Fatalf("standalone creds incomplete: %+v", creds)
354 }
355 if creds.Host != "email-smtp.eu-west-1.amazonaws.com" || creds.Port != "587" {
356 t.Fatalf("standalone coordinates wrong: %+v", creds)
357 }
358 own := StandaloneUserName("bag", "harbourmail", "laptop")
359 if _, ok := p.Users[own]; !ok {
360 t.Fatal("standalone credential did not get its own IAM user")
361 }
362 // The delivered user still holds exactly its own key.
363 if len(p.Users[UserName("bag", "harbourmail")].Keys) != 1 {
364 t.Fatal("standalone minting touched the delivered user's key slots")
365 }
366
367 if err := RevokeStandalone(ctx, s, p, "bag", "harbourmail", "laptop"); err != nil {
368 t.Fatalf("RevokeStandalone: %v", err)
369 }
370 if _, ok := p.Users[own]; ok {
371 t.Fatal("revoke left the standalone user behind")
372 }
373 after, _, _ := Get(ctx, s, "bag", "harbourmail")
374 if after.Provisioned.KeyID != delivered {
375 t.Fatal("revoking a standalone credential disturbed delivery")
376 }
377}
378
379// CreateStandalone must refuse an invalid name — a credential name is not
380// an env prefix, so ValidEnvPrefix must play no part in the guard (F5).
381func TestCreateStandaloneRefusesAnInvalidName(t *testing.T) {
382 ctx, s, p, _, now := enableFixture(t)
383 if _, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail",
384 "Not Valid!", "owner@example.test", now); err == nil {
385 t.Fatal("CreateStandalone accepted an invalid name")
386 }
387}
388
389// Teardown removes the identity only when the LAST app referencing it goes.
390// Otherwise one app's teardown revokes a sibling's verified domain.
391func TestTeardownKeepsASharedDomainAlive(t *testing.T) {
392 ctx, s, p, pub, now := enableFixture(t)
393 Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
394 // A sibling app joins the same domain.
395 if _, _, err := EnsureDomain(ctx, s, p, memWriterForTest(), testDeployment(),
396 "bag", "marketing", "harbourmail.bag.oncarlos.com", now); err != nil {
397 t.Fatalf("sibling EnsureDomain: %v", err)
398 }
399
400 // M3: thread a real, inspectable writer through (rather than a fresh
401 // throwaway one) so a wrongly-triggered record deletion would actually
402 // be visible to this test.
403 w := dnsrec.NewMemWriter()
404 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(),
405 "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil {
406 t.Fatalf("re-publish into the inspectable writer: %v", err)
407 }
408 if len(w.Records) == 0 {
409 t.Fatal("fixture broken: no records published to inspect")
410 }
411
412 if err := Teardown(ctx, s, p, w, testDeployment(), "bag", "harbourmail"); err != nil {
413 t.Fatalf("Teardown: %v", err)
414 }
415 if _, ok := p.Identities[identityKey("eu-west-1", "harbourmail.bag.oncarlos.com")]; !ok {
416 t.Fatal("teardown deleted an identity a sibling app still references")
417 }
418 dom, _, _ := GetDomain(ctx, s, "bag", "harbourmail.bag.oncarlos.com")
419 if len(dom.Apps) != 1 || dom.Apps[0] != "marketing" {
420 t.Fatalf("apps = %v, want [marketing]", dom.Apps)
421 }
422 if len(w.Records) == 0 {
423 t.Fatal("teardown deleted DNS records for a domain a sibling app still references")
424 }
425}
426
427// I4: the destroy branch's decision must be made on a FRESH read, not the
428// snapshot ListDomains took at the top of Teardown. A sibling that joins
429// this domain between that snapshot and the decision must not have its
430// identity, DKIM tokens, and (for a custom domain) hand-published DNS
431// destroyed out from under it.
432func TestTeardownDestroyDecisionSeesASiblingThatJoinsBetweenListAndDecision(t *testing.T) {
433 ctx, s, p, pub, now := enableFixture(t)
434 Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
435
436 domain := "harbourmail.bag.oncarlos.com"
437 rs := &raceGetStore{
438 Store: s,
439 key: DomainKey("bag", domain),
440 trigger: 2, // 1st Get is ListDomains' own unmarshal pass; 2nd is Teardown's decision re-read.
441 inject: func() error {
442 cur, etag, err := GetDomain(ctx, s, "bag", domain)
443 if err != nil {
444 return err
445 }
446 cur.AddApp("marketing")
447 _, err = PutDomain(ctx, s, "bag", cur, etag)
448 return err
449 },
450 }
451
452 if err := Teardown(ctx, rs, p, memWriterForTest(), testDeployment(), "bag", "harbourmail"); err != nil {
453 t.Fatalf("Teardown: %v", err)
454 }
455 if _, ok := p.Identities[identityKey("eu-west-1", domain)]; !ok {
456 t.Fatal("teardown destroyed an identity a sibling that joined mid-teardown still references")
457 }
458 dom, _, err := GetDomain(ctx, s, "bag", domain)
459 if err != nil {
460 t.Fatalf("GetDomain: %v", err)
461 }
462 if dom == nil {
463 t.Fatal("teardown deleted the domain record even though a sibling joined mid-teardown")
464 }
465 if len(dom.Apps) != 1 || dom.Apps[0] != "marketing" {
466 t.Fatalf("apps = %v, want [marketing]", dom.Apps)
467 }
468}
469
470// raceGetStore wraps a release.Store and, on the Nth Get call for one
471// specific key, runs inject() immediately before that call returns. It
472// models a write landing between an earlier snapshot read (ListDomains' own
473// pass) and a later re-read (Teardown's decision), inside one synchronous
474// call, without real concurrency.
475type raceGetStore struct {
476 release.Store
477 key string
478 trigger int
479 calls int
480 inject func() error
481}
482
483func (r *raceGetStore) Get(ctx context.Context, key string) ([]byte, string, error) {
484 if key == r.key {
485 r.calls++
486 if r.calls == r.trigger && r.inject != nil {
487 if err := r.inject(); err != nil {
488 return nil, "", err
489 }
490 }
491 }
492 return r.Store.Get(ctx, key)
493}
494
495// Teardown removes the identity, every principal (delivered AND standalone —
496// I3), and every DNS record it published (M3), once it is the LAST
497// referrer.
498func TestTeardownRemovesTheIdentityWhenTheLastAppGoes(t *testing.T) {
499 ctx, s, p, pub, now := enableFixture(t)
500 Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now)
501 if _, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail",
502 "laptop", "owner@example.test", now); err != nil {
503 t.Fatalf("CreateStandalone: %v", err)
504 }
505 standaloneUser := StandaloneUserName("bag", "harbourmail", "laptop")
506 if _, ok := p.Users[standaloneUser]; !ok {
507 t.Fatal("fixture broken: standalone user was not created")
508 }
509
510 // M3: thread a real, inspectable writer through.
511 w := dnsrec.NewMemWriter()
512 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(),
513 "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil {
514 t.Fatalf("re-publish into the inspectable writer: %v", err)
515 }
516 if len(w.Records) == 0 {
517 t.Fatal("fixture broken: no records published to inspect")
518 }
519
520 if err := Teardown(ctx, s, p, w, testDeployment(), "bag", "harbourmail"); err != nil {
521 t.Fatalf("Teardown: %v", err)
522 }
523 if _, ok := p.Identities[identityKey("eu-west-1", "harbourmail.bag.oncarlos.com")]; ok {
524 t.Fatal("teardown left the identity behind after the last app went")
525 }
526 if _, ok := p.Users[UserName("bag", "harbourmail")]; ok {
527 t.Fatal("teardown left the delivered IAM user behind")
528 }
529 if _, ok := p.Users[standaloneUser]; ok {
530 t.Fatal("teardown left the standalone IAM user behind")
531 }
532 if len(w.Records) != 0 {
533 t.Fatalf("teardown left DNS records behind: %v", w.Records)
534 }
535 rec, _, err := Get(ctx, s, "bag", "harbourmail")
536 if err != nil {
537 t.Fatalf("Get: %v", err)
538 }
539 if rec != nil {
540 t.Fatal("teardown left the app's email record behind")
541 }
542}
543
544// I2 (review follow-up): a hard `return err` from DisassociateTenantResource
545// used to run before DeleteIdentity and before releaseDomainClaim. If the
546// control-plane role lacks ses:DeleteTenantResourceAssociation (spec §4.2),
547// AccessDenied is neither AlreadyExists nor NotFound, so it was never
548// swallowed — and that failed every teardown where the app was the last
549// referrer of a domain, stranding the domain's global claim, even for a
550// domain that predates this release and was never actually associated
551// (spec §4.2's first prerequisite). This test pins the fix: a failed
552// disassociate is logged, not fatal, and Teardown still reaches
553// DeleteIdentity and finishes when nothing is really associated.
554func TestTeardownReachesDeleteIdentityDespiteAFailedDisassociate(t *testing.T) {
555 ctx, s, p, pub, now := enableFixture(t)
556 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
557 t.Fatalf("Enable: %v", err)
558 }
559 w := dnsrec.NewMemWriter()
560 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(),
561 "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil {
562 t.Fatalf("re-publish into the inspectable writer: %v", err)
563 }
564
565 // Stand in for a domain that predates this release: verified, but with
566 // no real tenant association, because nothing before this release ever
567 // created one. Clearing it after EnsureDomain (which did associate it,
568 // same as it would for any domain added today) reaches that state
569 // without a second fixture.
570 ident := p.Identities[identityKey("eu-west-1", "harbourmail.bag.oncarlos.com")]
571 delete(p.TenantResources[TenantName("bag")], ident.ARN)
572
573 p.FailNext["DisassociateTenantResource"] = errors.New("AccessDenied: ses:DeleteTenantResourceAssociation")
574
575 if err := Teardown(ctx, s, p, w, testDeployment(), "bag", "harbourmail"); err != nil {
576 t.Fatalf("Teardown: %v, want the failed disassociate logged and teardown to still finish", err)
577 }
578 if _, ok := p.Identities[identityKey("eu-west-1", "harbourmail.bag.oncarlos.com")]; ok {
579 t.Fatal("teardown left the identity behind after a failed but inconsequential disassociate")
580 }
581}
582
583// The real protection stays: when the identity IS still associated, a
584// failed disassociate must not let DeleteIdentity through anyway — SES
585// itself refuses that delete for the same reason (confirmed against the
586// live API, spec §4.1), and this is what makes that refusal the hard
587// failure instead of the disassociate call.
588func TestTeardownStillRefusesADeleteWhileReallyAssociated(t *testing.T) {
589 ctx, s, p, pub, now := enableFixture(t)
590 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
591 t.Fatalf("Enable: %v", err)
592 }
593 w := dnsrec.NewMemWriter()
594 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(),
595 "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil {
596 t.Fatalf("re-publish into the inspectable writer: %v", err)
597 }
598
599 // Left associated on purpose: EnsureDomain associated it above and
600 // nothing has removed it, so this is the case DeleteIdentity's own
601 // guard exists for.
602 p.FailNext["DisassociateTenantResource"] = errors.New("AccessDenied: ses:DeleteTenantResourceAssociation")
603
604 err := Teardown(ctx, s, p, w, testDeployment(), "bag", "harbourmail")
605 if err == nil {
606 t.Fatal("Teardown succeeded despite the identity still being associated — the real protection is gone")
607 }
608 if !strings.Contains(err.Error(), "still associated") {
609 t.Fatalf("Teardown error = %v, want DeleteIdentity's own \"still associated\" refusal", err)
610 }
611 if _, ok := p.Identities[identityKey("eu-west-1", "harbourmail.bag.oncarlos.com")]; !ok {
612 t.Fatal("the identity was deleted despite still being associated")
613 }
614}
615
616// Teardown must delete BOTH objects, not just the current one. Every other
617// Teardown fixture builds its record through Enable/Declare, which write
618// only senders/app.json, so a fixture like those would pass whether or not
619// the legacy delete at walk.go's end is even there. This one seeds
620// email.json directly, the way an app that has never been migrated actually
621// has it, and checks the legacy key by reading the store directly rather
622// than through Get: Get falls back to email.json, so it would read back
623// nil-ish success even if the legacy object were left behind.
624func TestTeardownDeletesTheLegacyRecordToo(t *testing.T) {
625 ctx := context.Background()
626 s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
627 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
628
629 legacy := &Record{Schema: Schema, App: "harbourmail",
630 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"},
631 Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-harbourmail", KeyID: "AKIA1", Active: true, At: now},
632 }
633 body, err := json.Marshal(legacy)
634 if err != nil {
635 t.Fatal(err)
636 }
637 if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), body, ""); err != nil {
638 t.Fatal(err)
639 }
640
641 w := dnsrec.NewMemWriter()
642 if err := Teardown(ctx, s, p, w, testDeployment(), "bag", "harbourmail"); err != nil {
643 t.Fatalf("Teardown: %v", err)
644 }
645
646 if _, _, err := s.Get(ctx, Key("bag", "harbourmail")); !errors.Is(err, os.ErrNotExist) {
647 t.Fatalf("senders/app.json survived teardown: %v", err)
648 }
649 if _, _, err := s.Get(ctx, LegacyKey("bag", "harbourmail")); !errors.Is(err, os.ErrNotExist) {
650 t.Fatalf("email.json survived teardown — a ghost record that would resurrect this app's email on the next Get: %v", err)
651 }
652}
653
654// An app sender in per-instance mode refuses to resume. Nothing sets the
655// flag in this release, but the refusal has to be in place before anything
656// can: resumeStaleCapPause reactivates every key it touches, including every
657// standalone, so a cap-paused app sender would otherwise re-arm on the next
658// UTC day roll the credential every instance still holds.
659func TestPerInstanceModeRefusesAResume(t *testing.T) {
660 ctx, s, p, pub, now := enableFixture(t)
661 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
662 t.Fatal(err)
663 }
664 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, PauseKindCap, now); err != nil {
665 t.Fatal(err)
666 }
667 sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
668 if err != nil {
669 t.Fatal(err)
670 }
671 sn.PerInstance = true
672 sn.Provisioned.PauseKind = PauseKindPerInstance
673 if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
674 t.Fatal(err)
675 }
676 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", false, "", now); !errors.Is(err, ErrPerInstance) {
677 t.Fatalf("resume in per-instance mode = %v, want ErrPerInstance", err)
678 }
679 // Pausing harder is still allowed: the refusal is about re-arming a
680 // credential, not about turning one off.
681 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, PauseKindRate, now); err != nil {
682 t.Fatalf("pausing an app sender in per-instance mode was refused: %v", err)
683 }
684}
685
686func memWriterForTest() dnsrec.Writer { return dnsrec.NewMemWriter() }
687