carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// RemoveDomain: `carlos email domains rm`, the release door the global
2// claim never had.
3//
4// The claim (domainclaim.go) is a deliberately global, create-only object
5// so two accounts cannot both hold one sending domain. Right — and until
6// this walk existed, the ONLY thing that ever released one was Teardown,
7// reachable only by permanently deleting the app that held it. A real
8// deployment hit exactly that: one app held a claim on a domain a second
9// app in a second account served and wanted to send from, and there was no
10// command in the product that could free it.
11//
12// What these tests pin is that the door does not reopen any of the holes
13// the claim closes: a domain a sibling app still uses keeps everything, the
14// release happens in an order a crash cannot strand, a custom domain's DNS
15// is never deleted, and no path here can read, disturb or probe another
16// account's claim.
17package mail
18
19import (
20 "context"
21 "errors"
22 "strings"
23 "testing"
24 "time"
25
26 "github.com/carlosframework/platform/internal/dnsrec"
27 "github.com/carlosframework/platform/internal/release"
28)
29
30// removeFixture is one account, one shared CUSTOM domain, two referring
31// apps — the shape every interesting question about this verb is asked in.
32// Custom rather than platform because the DNS rule (the customer's records
33// are the customer's to remove) only has teeth on a custom domain.
34func removeFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *dnsrec.MemWriter, time.Time) {
35 t.Helper()
36 ctx := context.Background()
37 s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared-aws-account"), dnsrec.NewMemWriter()
38 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
39 for _, app := range []string{"app1", "app2"} {
40 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", app, "shared.example", now); err != nil {
41 t.Fatalf("EnsureDomain %s: %v", app, err)
42 }
43 }
44 return ctx, s, p, w, now
45}
46
47// A sibling app still referring to the domain means NOTHING is released:
48// not the identity, not the DNS, and above all not the claim. A stranger
49// must still be refused — this is the exact failure Teardown's own comment
50// warns about ("would let a stranger claim a domain a sibling app is still
51// sending from"), and a naive "just delete the claim object" reopens it.
52func TestRemoveDomainKeepsADomainASiblingStillUses(t *testing.T) {
53 ctx, s, p, w, now := removeFixture(t)
54
55 out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example")
56 if err != nil {
57 t.Fatalf("RemoveDomain: %v", err)
58 }
59 if out.TornDown {
60 t.Fatalf("torn down while app2 still refers to it: %+v", out)
61 }
62 if len(out.Remaining) != 1 || out.Remaining[0] != "app2" {
63 t.Fatalf("remaining = %v, want [app2]", out.Remaining)
64 }
65 rec, _, err := GetDomain(ctx, s, "aaa", "shared.example")
66 if err != nil {
67 t.Fatalf("GetDomain: %v", err)
68 }
69 if rec == nil || len(rec.Apps) != 1 || rec.Apps[0] != "app2" {
70 t.Fatalf("record = %+v, want one referrer app2", rec)
71 }
72 if _, ok := p.Identities[identityKey("eu-west-1", "shared.example")]; !ok {
73 t.Fatal("removing one referrer destroyed the identity the sibling still sends with")
74 }
75 if claim, err := getDomainClaim(ctx, s, "shared.example"); err != nil || claim == nil {
76 t.Fatalf("claim released while a sibling still uses the domain: claim=%+v err=%v", claim, err)
77 }
78 stranger := NewMemProvisioner("shared-aws-account")
79 if _, _, err := EnsureDomain(ctx, s, stranger, w, testDeployment(), "bbb", "evilapp", "shared.example", now); err == nil {
80 t.Fatal("a stranger claimed a domain a sibling app is still sending from")
81 }
82}
83
84// The last referrer leaving is what releases the claim — and the proof is
85// that a DIFFERENT account can then complete the whole ensure walk on the
86// same domain, which is the outcome the real deployment could not reach.
87func TestRemoveDomainLastReferrerFreesTheDomainForAnotherAccount(t *testing.T) {
88 ctx, s, p, w, now := removeFixture(t)
89
90 if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example"); err != nil {
91 t.Fatalf("remove app1: %v", err)
92 }
93 out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app2", "shared.example")
94 if err != nil {
95 t.Fatalf("remove app2: %v", err)
96 }
97 if !out.TornDown || len(out.Remaining) != 0 {
98 t.Fatalf("last referrer left without a teardown: %+v", out)
99 }
100 if _, ok := p.Identities[identityKey("eu-west-1", "shared.example")]; ok {
101 t.Fatal("the SES identity survived the last referrer leaving")
102 }
103 if rec, _, err := GetDomain(ctx, s, "aaa", "shared.example"); err != nil || rec != nil {
104 t.Fatalf("the domain record survived: %+v (err %v)", rec, err)
105 }
106 if claim, err := getDomainClaim(ctx, s, "shared.example"); err != nil || claim != nil {
107 t.Fatalf("the claim survived the last referrer leaving: %+v (err %v)", claim, err)
108 }
109
110 // The whole point: another account can now take the domain.
111 other := NewMemProvisioner("shared-aws-account")
112 rec, _, err := EnsureDomain(ctx, s, other, w, testDeployment(), "bbb", "jam", "shared.example", now)
113 if err != nil {
114 t.Fatalf("a second account still cannot claim the released domain: %v", err)
115 }
116 if rec == nil || len(other.Identities) != 1 {
117 t.Fatal("the second account did not actually get its own identity")
118 }
119}
120
121// Removing the domain the app's own From sits at is refused. Enable rejects
122// a From that is not at a granted domain, so allowing it would move the
123// breakage from this command — where a person can read it — to the app's
124// next re-enable, where it presents as "email silently stopped working".
125func TestRemoveDomainRefusesTheDomainTheAppSendsFrom(t *testing.T) {
126 ctx, s, p, _, _ := enableFixture(t)
127 domain := "harbourmail.bag.oncarlos.com"
128
129 _, err := RemoveDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail", domain)
130 if !errors.Is(err, ErrDomainIsFrom) {
131 t.Fatalf("RemoveDomain err = %v, want ErrDomainIsFrom", err)
132 }
133 // A refusal must cost nothing and leave nothing half-done.
134 if rec, _, err := GetDomain(ctx, s, "bag", domain); err != nil || rec == nil {
135 t.Fatalf("the refusal still removed the record: %+v (err %v)", rec, err)
136 }
137 if claim, err := getDomainClaim(ctx, s, domain); err != nil || claim == nil {
138 t.Fatalf("the refusal still released the claim: %+v (err %v)", claim, err)
139 }
140 if _, ok := p.Identities[identityKey("eu-west-1", domain)]; !ok {
141 t.Fatal("the refusal still destroyed the identity")
142 }
143 // The error names the From, so a member knows what to move.
144 if !strings.Contains(err.Error(), "noreply@"+domain) {
145 t.Fatalf("refusal does not name the From address: %v", err)
146 }
147}
148
149// A CUSTOM domain's records live in the customer's zone: CARLOS never
150// published them and must not delete them. They come back instead, so a
151// door can tell the operator exactly which names just went stale.
152func TestRemoveDomainLeavesACustomDomainsDNSAloneAndNamesIt(t *testing.T) {
153 ctx := context.Background()
154 s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter()
155 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
156
157 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "custom.example", now); err != nil {
158 t.Fatalf("EnsureDomain: %v", err)
159 }
160 if len(w.Records) != 0 {
161 t.Fatalf("fixture broken: CARLOS published records for a custom domain: %v", w.Records)
162 }
163 out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "custom.example")
164 if err != nil {
165 t.Fatalf("RemoveDomain: %v", err)
166 }
167 if out.Kind != KindCustom {
168 t.Fatalf("kind = %q, want %q", out.Kind, KindCustom)
169 }
170 // Six records: three DKIM CNAMEs, the MAIL FROM MX, its SPF TXT and
171 // the DMARC TXT — the whole set the member was asked to publish.
172 if len(out.Orphaned) != 6 {
173 t.Fatalf("orphaned = %d records, want the whole set of 6: %+v", len(out.Orphaned), out.Orphaned)
174 }
175 var cnames int
176 for _, r := range out.Orphaned {
177 if r.Type == dnsrec.TypeCNAME {
178 cnames++
179 }
180 }
181 if cnames != 3 {
182 t.Fatalf("token-derived DKIM CNAMEs = %d, want 3", cnames)
183 }
184}
185
186// A PLATFORM domain's records are CARLOS's own, so they go — the same split
187// Teardown makes, for the same reason.
188func TestRemoveDomainDeletesAPlatformDomainsPublishedRecords(t *testing.T) {
189 ctx := context.Background()
190 s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter()
191 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
192
193 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "bag", "shop", "shop.bag.oncarlos.com", now); err != nil {
194 t.Fatalf("EnsureDomain: %v", err)
195 }
196 if len(w.Records) == 0 {
197 t.Fatal("fixture broken: nothing was published for a platform domain")
198 }
199 out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "bag", "shop", "shop.bag.oncarlos.com")
200 if err != nil {
201 t.Fatalf("RemoveDomain: %v", err)
202 }
203 if len(w.Records) != 0 {
204 t.Fatalf("platform records survived the removal: %v", w.Records)
205 }
206 if len(out.Orphaned) != 0 {
207 t.Fatalf("a platform domain reported orphaned records it does not own: %+v", out.Orphaned)
208 }
209}
210
211// A domain this app does not use is a refusal, not a silent success — and
212// running rm twice takes the same path, so "already removed" never reads as
213// "removed something else".
214func TestRemoveDomainRefusesADomainThisAppDoesNotUse(t *testing.T) {
215 ctx, s, p, w, _ := removeFixture(t)
216
217 _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app3", "shared.example")
218 if !errors.Is(err, ErrDomainNotReferenced) {
219 t.Fatalf("non-referrer err = %v, want ErrDomainNotReferenced", err)
220 }
221 if rec, _, err := GetDomain(ctx, s, "aaa", "shared.example"); err != nil || rec == nil || len(rec.Apps) != 2 {
222 t.Fatalf("a non-referrer's refusal changed the record: %+v (err %v)", rec, err)
223 }
224
225 if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example"); err != nil {
226 t.Fatalf("first remove: %v", err)
227 }
228 _, err = RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example")
229 if !errors.Is(err, ErrDomainNotReferenced) {
230 t.Fatalf("second remove err = %v, want the same ErrDomainNotReferenced", err)
231 }
232}
233
234// OWNER-GATING. A claim held by another account must be untouchable and
235// indistinguishable from a domain that was never claimed at all: the same
236// error, and nothing in it naming the holder. Anything else turns this door
237// into an oracle for who owns what.
238func TestRemoveDomainNeverTouchesAnotherAccountsClaim(t *testing.T) {
239 ctx := context.Background()
240 s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter()
241 p := NewMemProvisioner("shared-aws-account")
242 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
243
244 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "secretacct", "victimapp", "victim.example", now); err != nil {
245 t.Fatalf("EnsureDomain: %v", err)
246 }
247
248 _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "bbb", "evilapp", "victim.example")
249 if !errors.Is(err, ErrDomainNotReferenced) {
250 t.Fatalf("cross-account remove err = %v, want ErrDomainNotReferenced", err)
251 }
252 if strings.Contains(err.Error(), "secretacct") {
253 t.Fatalf("the refusal named the holding account: %v", err)
254 }
255 if claim, err := getDomainClaim(ctx, s, "victim.example"); err != nil || claim == nil || claim.Account != "secretacct" {
256 t.Fatalf("another account's claim was disturbed: %+v (err %v)", claim, err)
257 }
258 if _, ok := p.Identities[identityKey("eu-west-1", "victim.example")]; !ok {
259 t.Fatal("another account's identity was destroyed")
260 }
261 // A domain nobody ever claimed answers identically.
262 _, err2 := RemoveDomain(ctx, s, p, w, testDeployment(), "bbb", "evilapp", "unclaimed.example")
263 if err.Error() != strings.Replace(err2.Error(), "unclaimed.example", "victim.example", 1) {
264 t.Fatalf("claimed-by-another and never-claimed read differently:\n %v\n %v", err, err2)
265 }
266}
267
268// The orphaned claim: EnsureDomain failed after claimDomain and before
269// PutDomain, so a global claim exists with NO record behind it. Teardown
270// could sweep it, but only by deleting the whole app — an operator who did
271// not want to do that had no recourse at all.
272func TestRemoveDomainSweepsAClaimWithNoDomainRecord(t *testing.T) {
273 ctx := context.Background()
274 s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter()
275 p := NewMemProvisioner("shared-aws-account")
276 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
277
278 p.FailNext["EnsureIdentity"] = errors.New("SES throttled")
279 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "stranded.example", now); err == nil {
280 t.Fatal("expected the injected failure")
281 }
282 if claim, err := getDomainClaim(ctx, s, "stranded.example"); err != nil || claim == nil {
283 t.Fatalf("fixture broken: no stranded claim: %+v (err %v)", claim, err)
284 }
285
286 out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "stranded.example")
287 if err != nil {
288 t.Fatalf("RemoveDomain: %v", err)
289 }
290 if !out.ClaimOnly || !out.TornDown {
291 t.Fatalf("orphan sweep did not report itself as one: %+v", out)
292 }
293 if claim, err := getDomainClaim(ctx, s, "stranded.example"); err != nil || claim != nil {
294 t.Fatalf("the stranded claim survived: %+v (err %v)", claim, err)
295 }
296 other := NewMemProvisioner("shared-aws-account")
297 if _, _, err := EnsureDomain(ctx, s, other, w, testDeployment(), "bbb", "jam", "stranded.example", now); err != nil {
298 t.Fatalf("another account still cannot claim the freed domain: %v", err)
299 }
300}
301
302// THE ORDERING INVARIANT (walk.go's RELEASE BEFORE DELETE comment). A crash
303// between releasing the claim and deleting the record must be recoverable —
304// which it is precisely because the release came first. The reverse order
305// strands the claim forever: the record would be gone, so nothing would
306// ever list the domain again to reach the release call.
307func TestRemoveDomainClaimReleaseSurvivesAFailedRecordDelete(t *testing.T) {
308 ctx := context.Background()
309 inner, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter()
310 p := NewMemProvisioner("shared-aws-account")
311 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
312
313 if _, _, err := EnsureDomain(ctx, inner, p, w, testDeployment(), "aaa", "app1", "mine.example", now); err != nil {
314 t.Fatalf("EnsureDomain: %v", err)
315 }
316 s := &failDeleteOnce{Store: inner, key: DomainKey("aaa", "mine.example")}
317 if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "mine.example"); err == nil {
318 t.Fatal("expected the injected record-delete failure to surface")
319 }
320 if claim, err := getDomainClaim(ctx, inner, "mine.example"); err != nil || claim != nil {
321 t.Fatalf("claim survived a failed record delete: %+v (err %v)", claim, err)
322 }
323 // Free immediately, with no retry required of anyone.
324 other := NewMemProvisioner("shared-aws-account")
325 if _, _, err := EnsureDomain(ctx, inner, other, w, testDeployment(), "bbb", "jam", "mine.example", now); err != nil {
326 t.Fatalf("another account cannot claim the released domain: %v", err)
327 }
328 // And the stale record is still swept by the app's own teardown, which
329 // must not error trying to release a claim that is now somebody else's.
330 if err := Teardown(ctx, inner, p, w, testDeployment(), "aaa", "app1"); err != nil {
331 t.Fatalf("teardown did not repair the leftover record: %v", err)
332 }
333}
334
335// The departing app's IAM policy must SHRINK. EnsureUser writes a policy
336// wholesale from the current granted set and nothing else recomputes it
337// when a domain leaves — so without RemoveDomain's own RefreshPolicies
338// call, an app that drops a domain a sibling keeps alive goes on holding
339// ses:SendRawEmail at a live identity ARN it no longer has any claim to.
340func TestRemoveDomainShrinksTheDepartingAppsPolicy(t *testing.T) {
341 ctx, s, p, pub, now := enableFixture(t)
342 extra := "extra.bag.oncarlos.com"
343 w := dnsrec.NewMemWriter()
344
345 // A second app joins the extra domain too, so removing harbourmail's
346 // referral leaves the identity ALIVE — the case where a stale policy is
347 // a live over-grant rather than a dangling string.
348 for _, app := range []string{"harbourmail", "marketing"} {
349 if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "bag", app, extra, now); err != nil {
350 t.Fatalf("EnsureDomain %s: %v", app, err)
351 }
352 }
353 p.SetIdentityStatus("eu-west-1", extra, Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"})
354 if _, err := VerifyDomain(ctx, s, p, testDeployment(), "bag", extra, "eu-west-1", now); err != nil {
355 t.Fatalf("VerifyDomain: %v", err)
356 }
357 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
358 t.Fatalf("Enable: %v", err)
359 }
360 user := p.Users[UserName("bag", "harbourmail")]
361 if user == nil || !strings.Contains(user.Policy, extra) {
362 t.Fatalf("fixture broken: policy does not name %s:\n%v", extra, user)
363 }
364
365 if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "bag", "harbourmail", extra); err != nil {
366 t.Fatalf("RemoveDomain: %v", err)
367 }
368 user = p.Users[UserName("bag", "harbourmail")]
369 if user == nil {
370 t.Fatal("the delivered principal disappeared")
371 }
372 if strings.Contains(user.Policy, extra) {
373 t.Fatalf("policy still grants sending at a domain this app no longer uses:\n%s", user.Policy)
374 }
375 if !strings.Contains(user.Policy, "harbourmail.bag.oncarlos.com") {
376 t.Fatalf("policy lost the domain this app still sends from:\n%s", user.Policy)
377 }
378 if _, ok := p.Identities[identityKey("eu-west-1", extra)]; !ok {
379 t.Fatal("the identity the sibling app still uses was destroyed")
380 }
381}
382
383// The claim must NOT be released when the identity could not actually be
384// destroyed. detachDomainApp reaches releaseDomainClaim only after
385// DeleteIdentity succeeds, and SES refuses that delete while a tenant
386// association still exists (spec §4.1) — so a `rm` that trips it leaves the
387// domain exactly as it was. Releasing anyway is the one thing this verb
388// must never do: it would hand a stranger a live identity's ARN and its
389// already-minted DKIM tokens.
390func TestRemoveDomainKeepsTheClaimWhenTheIdentityCannotBeDeleted(t *testing.T) {
391 ctx, s, p, w, _ := removeFixture(t)
392 if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example"); err != nil {
393 t.Fatalf("remove app1: %v", err)
394 }
395
396 // Left associated on purpose: EnsureDomain associated it and nothing
397 // has removed it, so this is the case DeleteIdentity's guard exists for.
398 p.FailNext["DisassociateTenantResource"] = errors.New("AccessDenied: ses:DeleteTenantResourceAssociation")
399
400 if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app2", "shared.example"); err == nil {
401 t.Fatal("rm succeeded despite the identity still being associated")
402 }
403 if claim, err := getDomainClaim(ctx, s, "shared.example"); err != nil || claim == nil {
404 t.Fatalf("the claim was released while the identity is still alive: %+v (err %v)", claim, err)
405 }
406 if _, ok := p.Identities[identityKey("eu-west-1", "shared.example")]; !ok {
407 t.Fatal("the identity was deleted despite still being associated")
408 }
409 stranger := NewMemProvisioner("shared-aws-account")
410 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
411 if _, _, err := EnsureDomain(ctx, s, stranger, w, testDeployment(), "bbb", "evilapp", "shared.example", now); err == nil {
412 t.Fatal("a stranger claimed a domain whose identity is still live")
413 }
414}
415
416// The mirror of Teardown's own I2 case: a failed disassociate that does not
417// matter (nothing was really associated) must be logged, not fatal, so `rm`
418// still reaches DeleteIdentity and releases the claim. A hard error here
419// would recreate the permanent stranding this verb exists to end.
420func TestRemoveDomainFinishesDespiteAnInconsequentialDisassociateFailure(t *testing.T) {
421 ctx, s, p, w, _ := removeFixture(t)
422 if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example"); err != nil {
423 t.Fatalf("remove app1: %v", err)
424 }
425
426 // Stand in for a domain that predates the tenant-association release:
427 // verified, but never actually associated.
428 ident := p.Identities[identityKey("eu-west-1", "shared.example")]
429 delete(p.TenantResources[TenantName("aaa")], ident.ARN)
430 p.FailNext["DisassociateTenantResource"] = errors.New("AccessDenied: ses:DeleteTenantResourceAssociation")
431
432 out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app2", "shared.example")
433 if err != nil {
434 t.Fatalf("RemoveDomain: %v, want the failed disassociate logged and the removal to finish", err)
435 }
436 if !out.TornDown {
437 t.Fatalf("removal did not tear down: %+v", out)
438 }
439 if claim, err := getDomainClaim(ctx, s, "shared.example"); err != nil || claim != nil {
440 t.Fatalf("the claim was stranded by an inconsequential disassociate failure: %+v (err %v)", claim, err)
441 }
442}
443