// RemoveDomain: `carlos email domains rm`, the release door the global // claim never had. // // The claim (domainclaim.go) is a deliberately global, create-only object // so two accounts cannot both hold one sending domain. Right — and until // this walk existed, the ONLY thing that ever released one was Teardown, // reachable only by permanently deleting the app that held it. A real // deployment hit exactly that: one app held a claim on a domain a second // app in a second account served and wanted to send from, and there was no // command in the product that could free it. // // What these tests pin is that the door does not reopen any of the holes // the claim closes: a domain a sibling app still uses keeps everything, the // release happens in an order a crash cannot strand, a custom domain's DNS // is never deleted, and no path here can read, disturb or probe another // account's claim. package mail import ( "context" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/release" ) // removeFixture is one account, one shared CUSTOM domain, two referring // apps — the shape every interesting question about this verb is asked in. // Custom rather than platform because the DNS rule (the customer's records // are the customer's to remove) only has teeth on a custom domain. func removeFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *dnsrec.MemWriter, time.Time) { t.Helper() ctx := context.Background() s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared-aws-account"), dnsrec.NewMemWriter() now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) for _, app := range []string{"app1", "app2"} { if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", app, "shared.example", now); err != nil { t.Fatalf("EnsureDomain %s: %v", app, err) } } return ctx, s, p, w, now } // A sibling app still referring to the domain means NOTHING is released: // not the identity, not the DNS, and above all not the claim. A stranger // must still be refused — this is the exact failure Teardown's own comment // warns about ("would let a stranger claim a domain a sibling app is still // sending from"), and a naive "just delete the claim object" reopens it. func TestRemoveDomainKeepsADomainASiblingStillUses(t *testing.T) { ctx, s, p, w, now := removeFixture(t) out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example") if err != nil { t.Fatalf("RemoveDomain: %v", err) } if out.TornDown { t.Fatalf("torn down while app2 still refers to it: %+v", out) } if len(out.Remaining) != 1 || out.Remaining[0] != "app2" { t.Fatalf("remaining = %v, want [app2]", out.Remaining) } rec, _, err := GetDomain(ctx, s, "aaa", "shared.example") if err != nil { t.Fatalf("GetDomain: %v", err) } if rec == nil || len(rec.Apps) != 1 || rec.Apps[0] != "app2" { t.Fatalf("record = %+v, want one referrer app2", rec) } if _, ok := p.Identities[identityKey("eu-west-1", "shared.example")]; !ok { t.Fatal("removing one referrer destroyed the identity the sibling still sends with") } if claim, err := getDomainClaim(ctx, s, "shared.example"); err != nil || claim == nil { t.Fatalf("claim released while a sibling still uses the domain: claim=%+v err=%v", claim, err) } stranger := NewMemProvisioner("shared-aws-account") if _, _, err := EnsureDomain(ctx, s, stranger, w, testDeployment(), "bbb", "evilapp", "shared.example", now); err == nil { t.Fatal("a stranger claimed a domain a sibling app is still sending from") } } // The last referrer leaving is what releases the claim — and the proof is // that a DIFFERENT account can then complete the whole ensure walk on the // same domain, which is the outcome the real deployment could not reach. func TestRemoveDomainLastReferrerFreesTheDomainForAnotherAccount(t *testing.T) { ctx, s, p, w, now := removeFixture(t) if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example"); err != nil { t.Fatalf("remove app1: %v", err) } out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app2", "shared.example") if err != nil { t.Fatalf("remove app2: %v", err) } if !out.TornDown || len(out.Remaining) != 0 { t.Fatalf("last referrer left without a teardown: %+v", out) } if _, ok := p.Identities[identityKey("eu-west-1", "shared.example")]; ok { t.Fatal("the SES identity survived the last referrer leaving") } if rec, _, err := GetDomain(ctx, s, "aaa", "shared.example"); err != nil || rec != nil { t.Fatalf("the domain record survived: %+v (err %v)", rec, err) } if claim, err := getDomainClaim(ctx, s, "shared.example"); err != nil || claim != nil { t.Fatalf("the claim survived the last referrer leaving: %+v (err %v)", claim, err) } // The whole point: another account can now take the domain. other := NewMemProvisioner("shared-aws-account") rec, _, err := EnsureDomain(ctx, s, other, w, testDeployment(), "bbb", "jam", "shared.example", now) if err != nil { t.Fatalf("a second account still cannot claim the released domain: %v", err) } if rec == nil || len(other.Identities) != 1 { t.Fatal("the second account did not actually get its own identity") } } // Removing the domain the app's own From sits at is refused. Enable rejects // a From that is not at a granted domain, so allowing it would move the // breakage from this command — where a person can read it — to the app's // next re-enable, where it presents as "email silently stopped working". func TestRemoveDomainRefusesTheDomainTheAppSendsFrom(t *testing.T) { ctx, s, p, _, _ := enableFixture(t) domain := "harbourmail.bag.oncarlos.com" _, err := RemoveDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail", domain) if !errors.Is(err, ErrDomainIsFrom) { t.Fatalf("RemoveDomain err = %v, want ErrDomainIsFrom", err) } // A refusal must cost nothing and leave nothing half-done. if rec, _, err := GetDomain(ctx, s, "bag", domain); err != nil || rec == nil { t.Fatalf("the refusal still removed the record: %+v (err %v)", rec, err) } if claim, err := getDomainClaim(ctx, s, domain); err != nil || claim == nil { t.Fatalf("the refusal still released the claim: %+v (err %v)", claim, err) } if _, ok := p.Identities[identityKey("eu-west-1", domain)]; !ok { t.Fatal("the refusal still destroyed the identity") } // The error names the From, so a member knows what to move. if !strings.Contains(err.Error(), "noreply@"+domain) { t.Fatalf("refusal does not name the From address: %v", err) } } // A CUSTOM domain's records live in the customer's zone: CARLOS never // published them and must not delete them. They come back instead, so a // door can tell the operator exactly which names just went stale. func TestRemoveDomainLeavesACustomDomainsDNSAloneAndNamesIt(t *testing.T) { ctx := context.Background() s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "custom.example", now); err != nil { t.Fatalf("EnsureDomain: %v", err) } if len(w.Records) != 0 { t.Fatalf("fixture broken: CARLOS published records for a custom domain: %v", w.Records) } out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "custom.example") if err != nil { t.Fatalf("RemoveDomain: %v", err) } if out.Kind != KindCustom { t.Fatalf("kind = %q, want %q", out.Kind, KindCustom) } // Six records: three DKIM CNAMEs, the MAIL FROM MX, its SPF TXT and // the DMARC TXT — the whole set the member was asked to publish. if len(out.Orphaned) != 6 { t.Fatalf("orphaned = %d records, want the whole set of 6: %+v", len(out.Orphaned), out.Orphaned) } var cnames int for _, r := range out.Orphaned { if r.Type == dnsrec.TypeCNAME { cnames++ } } if cnames != 3 { t.Fatalf("token-derived DKIM CNAMEs = %d, want 3", cnames) } } // A PLATFORM domain's records are CARLOS's own, so they go — the same split // Teardown makes, for the same reason. func TestRemoveDomainDeletesAPlatformDomainsPublishedRecords(t *testing.T) { ctx := context.Background() s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "bag", "shop", "shop.bag.oncarlos.com", now); err != nil { t.Fatalf("EnsureDomain: %v", err) } if len(w.Records) == 0 { t.Fatal("fixture broken: nothing was published for a platform domain") } out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "bag", "shop", "shop.bag.oncarlos.com") if err != nil { t.Fatalf("RemoveDomain: %v", err) } if len(w.Records) != 0 { t.Fatalf("platform records survived the removal: %v", w.Records) } if len(out.Orphaned) != 0 { t.Fatalf("a platform domain reported orphaned records it does not own: %+v", out.Orphaned) } } // A domain this app does not use is a refusal, not a silent success — and // running rm twice takes the same path, so "already removed" never reads as // "removed something else". func TestRemoveDomainRefusesADomainThisAppDoesNotUse(t *testing.T) { ctx, s, p, w, _ := removeFixture(t) _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app3", "shared.example") if !errors.Is(err, ErrDomainNotReferenced) { t.Fatalf("non-referrer err = %v, want ErrDomainNotReferenced", err) } if rec, _, err := GetDomain(ctx, s, "aaa", "shared.example"); err != nil || rec == nil || len(rec.Apps) != 2 { t.Fatalf("a non-referrer's refusal changed the record: %+v (err %v)", rec, err) } if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example"); err != nil { t.Fatalf("first remove: %v", err) } _, err = RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example") if !errors.Is(err, ErrDomainNotReferenced) { t.Fatalf("second remove err = %v, want the same ErrDomainNotReferenced", err) } } // OWNER-GATING. A claim held by another account must be untouchable and // indistinguishable from a domain that was never claimed at all: the same // error, and nothing in it naming the holder. Anything else turns this door // into an oracle for who owns what. func TestRemoveDomainNeverTouchesAnotherAccountsClaim(t *testing.T) { ctx := context.Background() s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() p := NewMemProvisioner("shared-aws-account") now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "secretacct", "victimapp", "victim.example", now); err != nil { t.Fatalf("EnsureDomain: %v", err) } _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "bbb", "evilapp", "victim.example") if !errors.Is(err, ErrDomainNotReferenced) { t.Fatalf("cross-account remove err = %v, want ErrDomainNotReferenced", err) } if strings.Contains(err.Error(), "secretacct") { t.Fatalf("the refusal named the holding account: %v", err) } if claim, err := getDomainClaim(ctx, s, "victim.example"); err != nil || claim == nil || claim.Account != "secretacct" { t.Fatalf("another account's claim was disturbed: %+v (err %v)", claim, err) } if _, ok := p.Identities[identityKey("eu-west-1", "victim.example")]; !ok { t.Fatal("another account's identity was destroyed") } // A domain nobody ever claimed answers identically. _, err2 := RemoveDomain(ctx, s, p, w, testDeployment(), "bbb", "evilapp", "unclaimed.example") if err.Error() != strings.Replace(err2.Error(), "unclaimed.example", "victim.example", 1) { t.Fatalf("claimed-by-another and never-claimed read differently:\n %v\n %v", err, err2) } } // The orphaned claim: EnsureDomain failed after claimDomain and before // PutDomain, so a global claim exists with NO record behind it. Teardown // could sweep it, but only by deleting the whole app — an operator who did // not want to do that had no recourse at all. func TestRemoveDomainSweepsAClaimWithNoDomainRecord(t *testing.T) { ctx := context.Background() s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() p := NewMemProvisioner("shared-aws-account") now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) p.FailNext["EnsureIdentity"] = errors.New("SES throttled") if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "stranded.example", now); err == nil { t.Fatal("expected the injected failure") } if claim, err := getDomainClaim(ctx, s, "stranded.example"); err != nil || claim == nil { t.Fatalf("fixture broken: no stranded claim: %+v (err %v)", claim, err) } out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "stranded.example") if err != nil { t.Fatalf("RemoveDomain: %v", err) } if !out.ClaimOnly || !out.TornDown { t.Fatalf("orphan sweep did not report itself as one: %+v", out) } if claim, err := getDomainClaim(ctx, s, "stranded.example"); err != nil || claim != nil { t.Fatalf("the stranded claim survived: %+v (err %v)", claim, err) } other := NewMemProvisioner("shared-aws-account") if _, _, err := EnsureDomain(ctx, s, other, w, testDeployment(), "bbb", "jam", "stranded.example", now); err != nil { t.Fatalf("another account still cannot claim the freed domain: %v", err) } } // THE ORDERING INVARIANT (walk.go's RELEASE BEFORE DELETE comment). A crash // between releasing the claim and deleting the record must be recoverable — // which it is precisely because the release came first. The reverse order // strands the claim forever: the record would be gone, so nothing would // ever list the domain again to reach the release call. func TestRemoveDomainClaimReleaseSurvivesAFailedRecordDelete(t *testing.T) { ctx := context.Background() inner, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() p := NewMemProvisioner("shared-aws-account") now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) if _, _, err := EnsureDomain(ctx, inner, p, w, testDeployment(), "aaa", "app1", "mine.example", now); err != nil { t.Fatalf("EnsureDomain: %v", err) } s := &failDeleteOnce{Store: inner, key: DomainKey("aaa", "mine.example")} if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "mine.example"); err == nil { t.Fatal("expected the injected record-delete failure to surface") } if claim, err := getDomainClaim(ctx, inner, "mine.example"); err != nil || claim != nil { t.Fatalf("claim survived a failed record delete: %+v (err %v)", claim, err) } // Free immediately, with no retry required of anyone. other := NewMemProvisioner("shared-aws-account") if _, _, err := EnsureDomain(ctx, inner, other, w, testDeployment(), "bbb", "jam", "mine.example", now); err != nil { t.Fatalf("another account cannot claim the released domain: %v", err) } // And the stale record is still swept by the app's own teardown, which // must not error trying to release a claim that is now somebody else's. if err := Teardown(ctx, inner, p, w, testDeployment(), "aaa", "app1"); err != nil { t.Fatalf("teardown did not repair the leftover record: %v", err) } } // The departing app's IAM policy must SHRINK. EnsureUser writes a policy // wholesale from the current granted set and nothing else recomputes it // when a domain leaves — so without RemoveDomain's own RefreshPolicies // call, an app that drops a domain a sibling keeps alive goes on holding // ses:SendRawEmail at a live identity ARN it no longer has any claim to. func TestRemoveDomainShrinksTheDepartingAppsPolicy(t *testing.T) { ctx, s, p, pub, now := enableFixture(t) extra := "extra.bag.oncarlos.com" w := dnsrec.NewMemWriter() // A second app joins the extra domain too, so removing harbourmail's // referral leaves the identity ALIVE — the case where a stale policy is // a live over-grant rather than a dangling string. for _, app := range []string{"harbourmail", "marketing"} { if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "bag", app, extra, now); err != nil { t.Fatalf("EnsureDomain %s: %v", app, err) } } p.SetIdentityStatus("eu-west-1", extra, Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) if _, err := VerifyDomain(ctx, s, p, testDeployment(), "bag", extra, "eu-west-1", now); err != nil { t.Fatalf("VerifyDomain: %v", err) } if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil { t.Fatalf("Enable: %v", err) } user := p.Users[UserName("bag", "harbourmail")] if user == nil || !strings.Contains(user.Policy, extra) { t.Fatalf("fixture broken: policy does not name %s:\n%v", extra, user) } if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "bag", "harbourmail", extra); err != nil { t.Fatalf("RemoveDomain: %v", err) } user = p.Users[UserName("bag", "harbourmail")] if user == nil { t.Fatal("the delivered principal disappeared") } if strings.Contains(user.Policy, extra) { t.Fatalf("policy still grants sending at a domain this app no longer uses:\n%s", user.Policy) } if !strings.Contains(user.Policy, "harbourmail.bag.oncarlos.com") { t.Fatalf("policy lost the domain this app still sends from:\n%s", user.Policy) } if _, ok := p.Identities[identityKey("eu-west-1", extra)]; !ok { t.Fatal("the identity the sibling app still uses was destroyed") } } // The claim must NOT be released when the identity could not actually be // destroyed. detachDomainApp reaches releaseDomainClaim only after // DeleteIdentity succeeds, and SES refuses that delete while a tenant // association still exists (spec §4.1) — so a `rm` that trips it leaves the // domain exactly as it was. Releasing anyway is the one thing this verb // must never do: it would hand a stranger a live identity's ARN and its // already-minted DKIM tokens. func TestRemoveDomainKeepsTheClaimWhenTheIdentityCannotBeDeleted(t *testing.T) { ctx, s, p, w, _ := removeFixture(t) if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example"); err != nil { t.Fatalf("remove app1: %v", err) } // Left associated on purpose: EnsureDomain associated it and nothing // has removed it, so this is the case DeleteIdentity's guard exists for. p.FailNext["DisassociateTenantResource"] = errors.New("AccessDenied: ses:DeleteTenantResourceAssociation") if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app2", "shared.example"); err == nil { t.Fatal("rm succeeded despite the identity still being associated") } if claim, err := getDomainClaim(ctx, s, "shared.example"); err != nil || claim == nil { t.Fatalf("the claim was released while the identity is still alive: %+v (err %v)", claim, err) } if _, ok := p.Identities[identityKey("eu-west-1", "shared.example")]; !ok { t.Fatal("the identity was deleted despite still being associated") } stranger := NewMemProvisioner("shared-aws-account") now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) if _, _, err := EnsureDomain(ctx, s, stranger, w, testDeployment(), "bbb", "evilapp", "shared.example", now); err == nil { t.Fatal("a stranger claimed a domain whose identity is still live") } } // The mirror of Teardown's own I2 case: a failed disassociate that does not // matter (nothing was really associated) must be logged, not fatal, so `rm` // still reaches DeleteIdentity and releases the claim. A hard error here // would recreate the permanent stranding this verb exists to end. func TestRemoveDomainFinishesDespiteAnInconsequentialDisassociateFailure(t *testing.T) { ctx, s, p, w, _ := removeFixture(t) if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app1", "shared.example"); err != nil { t.Fatalf("remove app1: %v", err) } // Stand in for a domain that predates the tenant-association release: // verified, but never actually associated. ident := p.Identities[identityKey("eu-west-1", "shared.example")] delete(p.TenantResources[TenantName("aaa")], ident.ARN) p.FailNext["DisassociateTenantResource"] = errors.New("AccessDenied: ses:DeleteTenantResourceAssociation") out, err := RemoveDomain(ctx, s, p, w, testDeployment(), "aaa", "app2", "shared.example") if err != nil { t.Fatalf("RemoveDomain: %v, want the failed disassociate logged and the removal to finish", err) } if !out.TornDown { t.Fatalf("removal did not tear down: %+v", out) } if claim, err := getDomainClaim(ctx, s, "shared.example"); err != nil || claim != nil { t.Fatalf("the claim was stranded by an inconsequential disassociate failure: %+v (err %v)", claim, err) } }