| 1 | // Task 17a: the cross-account domain claim. Closes the hole review found in |
| 2 | // Task 17 — SES identities are per-AWS-ACCOUNT, not per-tenant, so any |
| 3 | // domain one CARLOS account verifies is verified globally. Before this, |
| 4 | // EnsureDomain computed §4's IAM policy from a domain set that was never |
| 5 | // checked for OWNERSHIP: a second account could call EnsureDomain on |
| 6 | // "victim.com", get the same identity ARN with no proof of control, and |
| 7 | // Enable would hand it a policy granting ses:SendRawEmail with |
| 8 | // ses:FromAddress *@victim.com. These tests pin the fix: a create-only, |
| 9 | // globally-keyed claim decided BEFORE EnsureIdentity ever runs. |
| 10 | package mail |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "errors" |
| 15 | "strings" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/dnsrec" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | // A second account claiming a domain the first already holds must be |
| 24 | // refused outright: no identity, no domain record. This is the vulnerable |
| 25 | // path from Task 17's review, closed. |
| 26 | // |
| 27 | // ONE SHARED MemProvisioner for both accounts (M2, review follow-up) — this |
| 28 | // is the honest model, not two. SES identities are per-AWS-ACCOUNT, and a |
| 29 | // CARLOS deployment has exactly ONE underlying AWS account behind it, |
| 30 | // shared by every CARLOS tenant account. Two separate MemProvisioner |
| 31 | // instances are the one setup that could NOT reproduce the actual bug: each |
| 32 | // would let account B mint its OWN identity object in its OWN fake, which |
| 33 | // looks like a failure caught (B "got an identity") but proves nothing |
| 34 | // about the real defect — in production B would get back the SAME ARN A |
| 35 | // already holds, immediately able to send as A's domain with no DNS |
| 36 | // control of its own. A single shared provisioner is the only shape where |
| 37 | // "B got no identity" actually means what it needs to mean. |
| 38 | func TestEnsureDomainRefusesASecondAccountsClaim(t *testing.T) { |
| 39 | ctx := context.Background() |
| 40 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 41 | p := NewMemProvisioner("shared-aws-account") |
| 42 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 43 | d := testDeployment() |
| 44 | |
| 45 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "victimapp", "victim.com", now); err != nil { |
| 46 | t.Fatalf("account A's first claim: %v", err) |
| 47 | } |
| 48 | |
| 49 | _, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "evilapp", "victim.com", now) |
| 50 | if err == nil { |
| 51 | t.Fatal("account B was allowed to claim a domain account A already holds") |
| 52 | } |
| 53 | if len(p.Identities) != 1 { |
| 54 | t.Fatalf("identities in the shared AWS account = %d, want 1 (A's only) — B must never have reached EnsureIdentity", len(p.Identities)) |
| 55 | } |
| 56 | if rec, _, err := GetDomain(ctx, s, "bbb", "victim.com"); err != nil { |
| 57 | t.Fatalf("GetDomain: %v", err) |
| 58 | } else if rec != nil { |
| 59 | t.Fatalf("account B got a domain record for a domain it does not own: %+v", rec) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // The refusal must be a cross-account information leak of exactly nothing: |
| 64 | // a losing caller must not learn WHO holds the domain by reading the error. |
| 65 | func TestEnsureDomainRefusalDoesNotNameTheHolder(t *testing.T) { |
| 66 | ctx := context.Background() |
| 67 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 68 | pA, pB := NewMemProvisioner("A"), NewMemProvisioner("B") |
| 69 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 70 | d := testDeployment() |
| 71 | |
| 72 | if _, _, err := EnsureDomain(ctx, s, pA, w, d, "secretacct", "victimapp", "victim.com", now); err != nil { |
| 73 | t.Fatalf("account A's first claim: %v", err) |
| 74 | } |
| 75 | _, _, err := EnsureDomain(ctx, s, pB, w, d, "bbb", "evilapp", "victim.com", now) |
| 76 | if err == nil { |
| 77 | t.Fatal("expected a refusal") |
| 78 | } |
| 79 | if strings.Contains(err.Error(), "secretacct") { |
| 80 | t.Fatalf("refusal named the holding account: %v", err) |
| 81 | } |
| 82 | if !errors.Is(err, ErrDomainClaimed) { |
| 83 | t.Fatalf("refusal was not ErrDomainClaimed: %v", err) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // The owning account re-running EnsureDomain on its own claimed domain is |
| 88 | // ordinary idempotent success — a claim must never block its own holder. |
| 89 | func TestEnsureDomainOwnAccountReRunIsIdempotent(t *testing.T) { |
| 90 | ctx := context.Background() |
| 91 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("A"), dnsrec.NewMemWriter() |
| 92 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 93 | d := testDeployment() |
| 94 | |
| 95 | first, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "myapp", "mine.com", now) |
| 96 | if err != nil { |
| 97 | t.Fatalf("first: %v", err) |
| 98 | } |
| 99 | second, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "myapp", "mine.com", now) |
| 100 | if err != nil { |
| 101 | t.Fatalf("second: %v", err) |
| 102 | } |
| 103 | f, sec := first.Regions["eu-west-1"], second.Regions["eu-west-1"] |
| 104 | if f.IdentityARN != sec.IdentityARN || f.DKIMTokens[0] != sec.DKIMTokens[0] { |
| 105 | t.Fatal("re-running under the SAME account changed identity/tokens") |
| 106 | } |
| 107 | if len(p.Identities) != 1 { |
| 108 | t.Fatalf("identities = %d, want 1", len(p.Identities)) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // Teardown of the last app referencing a domain must release the global |
| 113 | // claim, and only then — a sibling app still using the domain must keep it |
| 114 | // held. Once released, another account can claim it. |
| 115 | func TestTeardownReleasesClaimOnlyWhenLastAppGoes(t *testing.T) { |
| 116 | ctx := context.Background() |
| 117 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("A"), dnsrec.NewMemWriter() |
| 118 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 119 | d := testDeployment() |
| 120 | |
| 121 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app1", "shared.com", now); err != nil { |
| 122 | t.Fatalf("app1 ensure: %v", err) |
| 123 | } |
| 124 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app2", "shared.com", now); err != nil { |
| 125 | t.Fatalf("app2 ensure: %v", err) |
| 126 | } |
| 127 | |
| 128 | // Tear down app1: app2 still references the domain, so the claim must |
| 129 | // stay held — a stranger must NOT be able to claim a live domain out |
| 130 | // from under a sibling app. |
| 131 | if err := Teardown(ctx, s, p, w, d, "aaa", "app1"); err != nil { |
| 132 | t.Fatalf("teardown app1: %v", err) |
| 133 | } |
| 134 | pStranger := NewMemProvisioner("B") |
| 135 | if _, _, err := EnsureDomain(ctx, s, pStranger, w, d, "bbb", "strangerapp", "shared.com", now); err == nil { |
| 136 | t.Fatal("a stranger claimed a domain still referenced by a sibling app") |
| 137 | } |
| 138 | |
| 139 | // Tear down app2, the last referrer: NOW the claim must release. |
| 140 | if err := Teardown(ctx, s, p, w, d, "aaa", "app2"); err != nil { |
| 141 | t.Fatalf("teardown app2: %v", err) |
| 142 | } |
| 143 | rec, _, err := EnsureDomain(ctx, s, pStranger, w, d, "bbb", "strangerapp", "shared.com", now) |
| 144 | if err != nil { |
| 145 | t.Fatalf("stranger should be able to claim the released domain: %v", err) |
| 146 | } |
| 147 | if rec == nil || len(pStranger.Identities) != 1 { |
| 148 | t.Fatal("stranger did not actually get a fresh identity after the claim released") |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // A platform-suffixed domain is not exempt from the claim gate — one code |
| 153 | // path, no special case. (The separate ownership check below this domain |
| 154 | // name happens to satisfy — the label matches the caller's own account — |
| 155 | // is C1's fix and has its own dedicated tests further down.) |
| 156 | func TestEnsureDomainClaimsAPlatformDomainToo(t *testing.T) { |
| 157 | ctx := context.Background() |
| 158 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("A"), dnsrec.NewMemWriter() |
| 159 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 160 | d := testDeployment() |
| 161 | |
| 162 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "harbourmail", "harbourmail.aaa.oncarlos.com", now); err != nil { |
| 163 | t.Fatalf("EnsureDomain: %v", err) |
| 164 | } |
| 165 | claim, err := getDomainClaim(ctx, s, "harbourmail.aaa.oncarlos.com") |
| 166 | if err != nil { |
| 167 | t.Fatalf("getDomainClaim: %v", err) |
| 168 | } |
| 169 | if claim == nil || claim.Account != "aaa" { |
| 170 | t.Fatalf("platform domain did not get a claim: %+v", claim) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // The claim must be the FIRST durable side effect — written before |
| 175 | // EnsureIdentity ever runs. Inject a one-shot failure at EnsureIdentity: |
| 176 | // the claim must already be there, but no identity was created. A re-run by |
| 177 | // the SAME account must then succeed — the claim must not block its own |
| 178 | // owner, even though it was written before the walk that owns it finished. |
| 179 | func TestEnsureDomainWritesClaimBeforeIdentity(t *testing.T) { |
| 180 | ctx := context.Background() |
| 181 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("A"), dnsrec.NewMemWriter() |
| 182 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 183 | d := testDeployment() |
| 184 | |
| 185 | p.FailNext["EnsureIdentity"] = errors.New("SES down") |
| 186 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "myapp", "mine.com", now); err == nil { |
| 187 | t.Fatal("EnsureDomain succeeded despite an injected EnsureIdentity failure") |
| 188 | } |
| 189 | claim, err := getDomainClaim(ctx, s, "mine.com") |
| 190 | if err != nil { |
| 191 | t.Fatalf("getDomainClaim: %v", err) |
| 192 | } |
| 193 | if claim == nil || claim.Account != "aaa" { |
| 194 | t.Fatalf("claim missing after the injected failure: %+v", claim) |
| 195 | } |
| 196 | if len(p.Identities) != 0 { |
| 197 | t.Fatalf("identity was created despite the injected failure: %d", len(p.Identities)) |
| 198 | } |
| 199 | |
| 200 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "myapp", "mine.com", now) |
| 201 | if err != nil { |
| 202 | t.Fatalf("re-run by the claim's own owner must succeed: %v", err) |
| 203 | } |
| 204 | if rec == nil || len(p.Identities) != 1 { |
| 205 | t.Fatal("re-run did not complete provisioning") |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // ---- C1 (review follow-up): platform names need an ownership check too -- |
| 210 | // |
| 211 | // §4a originally claimed platform-suffixed names ("<app>.<sqid>.oncarlos.com") |
| 212 | // need no ownership check because they are "account-scoped by construction". |
| 213 | // That was wrong: DomainKindFor matches only the SUFFIX, and nothing |
| 214 | // compared the account label to the caller. Worse than the custom-domain |
| 215 | // case the claim alone protects, because CARLOS itself publishes the DKIM, |
| 216 | // MAIL FROM and DMARC records — the attacker needs no DNS control at all. |
| 217 | |
| 218 | // Account bbb must never obtain a platform name that names a DIFFERENT |
| 219 | // account's sqid — no identity, no DNS, and (since the check runs before |
| 220 | // the claim) no claim left behind either, so a refused attempt cannot even |
| 221 | // squat the name for later. |
| 222 | func TestEnsureDomainRefusesAPlatformNameForAnotherAccount(t *testing.T) { |
| 223 | ctx := context.Background() |
| 224 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared"), dnsrec.NewMemWriter() |
| 225 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 226 | d := testDeployment() |
| 227 | |
| 228 | _, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "evilapp", "harbourmail.aaa.oncarlos.com", now) |
| 229 | if err == nil { |
| 230 | t.Fatal("account bbb was allowed to claim account aaa's platform name") |
| 231 | } |
| 232 | if !errors.Is(err, ErrNotOwnPlatformName) { |
| 233 | t.Fatalf("err = %v, want ErrNotOwnPlatformName", err) |
| 234 | } |
| 235 | if len(p.Identities) != 0 { |
| 236 | t.Fatalf("an SES identity was created for a platform name bbb does not own: %d", len(p.Identities)) |
| 237 | } |
| 238 | if len(w.Records) != 0 { |
| 239 | t.Fatalf("DNS was published for a platform name bbb does not own: %d records", len(w.Records)) |
| 240 | } |
| 241 | if claim, err := getDomainClaim(ctx, s, "harbourmail.aaa.oncarlos.com"); err != nil || claim != nil { |
| 242 | t.Fatalf("a refused platform-name attempt left a claim behind: %+v", claim) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | // The mirror image: bbb asking for ITS OWN platform name succeeds exactly |
| 247 | // as before this fix — the ownership check must not become a second way to |
| 248 | // refuse a legitimate member. |
| 249 | func TestEnsureDomainAcceptsItsOwnPlatformName(t *testing.T) { |
| 250 | ctx := context.Background() |
| 251 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared"), dnsrec.NewMemWriter() |
| 252 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 253 | d := testDeployment() |
| 254 | |
| 255 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "myapp", "harbourmail.bbb.oncarlos.com", now) |
| 256 | if err != nil { |
| 257 | t.Fatalf("EnsureDomain: %v", err) |
| 258 | } |
| 259 | if rec.Kind != KindPlatform { |
| 260 | t.Fatalf("kind = %q, want platform", rec.Kind) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // The bare platform suffix names no account label at all, so no account — |
| 265 | // including a well-formed one — can own it. It was accepted before this |
| 266 | // fix, and CARLOS would have written a DMARC record at its own zone apex. |
| 267 | func TestEnsureDomainRefusesTheBarePlatformApex(t *testing.T) { |
| 268 | ctx := context.Background() |
| 269 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared"), dnsrec.NewMemWriter() |
| 270 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 271 | d := testDeployment() |
| 272 | |
| 273 | _, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "myapp", d.PlatformSuffix, now) |
| 274 | if err == nil { |
| 275 | t.Fatal("the bare platform apex was accepted") |
| 276 | } |
| 277 | if !errors.Is(err, ErrNotOwnPlatformName) { |
| 278 | t.Fatalf("err = %v, want ErrNotOwnPlatformName", err) |
| 279 | } |
| 280 | if len(w.Records) != 0 { |
| 281 | t.Fatalf("DNS was published at the platform's own zone apex: %d records", len(w.Records)) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // A custom domain names no account label — the ownership check must never |
| 286 | // fire for KindCustom, whatever the caller's account is. |
| 287 | func TestEnsureDomainCustomDomainUnaffectedByPlatformOwnershipCheck(t *testing.T) { |
| 288 | ctx := context.Background() |
| 289 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared"), dnsrec.NewMemWriter() |
| 290 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 291 | d := testDeployment() |
| 292 | |
| 293 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "myapp", "example.com", now) |
| 294 | if err != nil { |
| 295 | t.Fatalf("EnsureDomain on a custom domain was refused: %v", err) |
| 296 | } |
| 297 | if rec.Kind != KindCustom { |
| 298 | t.Fatalf("kind = %q, want custom", rec.Kind) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | // ---- I1 (review follow-up): a stranded claim must actually be releasable - |
| 303 | |
| 304 | // failDeleteOnce fails the FIRST Delete against one specific key, then |
| 305 | // behaves normally — the crash point I1's first strand is about: a failure |
| 306 | // between destroying the shared resource (identity/DNS) and deleting the |
| 307 | // account's own domain record. |
| 308 | type failDeleteOnce struct { |
| 309 | release.Store |
| 310 | key string |
| 311 | failed bool |
| 312 | } |
| 313 | |
| 314 | func (f *failDeleteOnce) Delete(ctx context.Context, key string) error { |
| 315 | if !f.failed && key == f.key { |
| 316 | f.failed = true |
| 317 | return errors.New("bucket delete failed") |
| 318 | } |
| 319 | return f.Store.Delete(ctx, key) |
| 320 | } |
| 321 | |
| 322 | // Strand 1: a failure deleting the account's own domain record must not |
| 323 | // strand the claim. Before I1's fix (release AFTER delete), a crash here |
| 324 | // meant the record survived on disk, ListDomains would list it again, but |
| 325 | // this exact branch had already tried and failed once — WORSE, if the |
| 326 | // record delete itself never succeeds on retry either, the release call |
| 327 | // was UNREACHABLE forever, because Teardown's only path to it ran through |
| 328 | // a record delete that keeps failing. Releasing FIRST (this fix) means the |
| 329 | // claim is gone immediately, regardless of whether the record delete ever |
| 330 | // succeeds. |
| 331 | func TestTeardownClaimReleaseSurvivesAFailedRecordDelete(t *testing.T) { |
| 332 | ctx := context.Background() |
| 333 | inner := release.NewDirStore(t.TempDir()) |
| 334 | w := dnsrec.NewMemWriter() |
| 335 | p := NewMemProvisioner("A") |
| 336 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 337 | d := testDeployment() |
| 338 | |
| 339 | if _, _, err := EnsureDomain(ctx, inner, p, w, d, "aaa", "myapp", "mine.com", now); err != nil { |
| 340 | t.Fatalf("ensure: %v", err) |
| 341 | } |
| 342 | |
| 343 | s := &failDeleteOnce{Store: inner, key: DomainKey("aaa", "mine.com")} |
| 344 | if err := Teardown(ctx, s, p, w, d, "aaa", "myapp"); err == nil { |
| 345 | t.Fatal("expected the injected record-delete failure to surface") |
| 346 | } |
| 347 | if claim, err := getDomainClaim(ctx, inner, "mine.com"); err != nil { |
| 348 | t.Fatalf("getDomainClaim: %v", err) |
| 349 | } else if claim != nil { |
| 350 | t.Fatalf("claim survived a failed record delete: %+v", claim) |
| 351 | } |
| 352 | |
| 353 | // A stranger can claim the domain immediately — no retry required. |
| 354 | pStranger := NewMemProvisioner("B") |
| 355 | if _, _, err := EnsureDomain(ctx, inner, pStranger, w, d, "bbb", "strangerapp", "mine.com", now); err != nil { |
| 356 | t.Fatalf("stranger should be able to claim the released domain: %v", err) |
| 357 | } |
| 358 | |
| 359 | // Retrying the original Teardown (past the one-shot failure) must still |
| 360 | // be self-healing, and must not error trying to release a claim it no |
| 361 | // longer holds (it's bbb's now). |
| 362 | if err := Teardown(ctx, inner, p, w, d, "aaa", "myapp"); err != nil { |
| 363 | t.Fatalf("retry did not repair: %v", err) |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | // Strand 2: the far more ORDINARY case — EnsureDomain fails after the claim |
| 368 | // but before any domain record is ever written (an SES throttle, a tenant |
| 369 | // limit, anything). Before I1's fix this was a PERMANENT dead end: |
| 370 | // Teardown's domain loop only visits domains with a record, and this one |
| 371 | // never got one, so the claim survived every Teardown forever — a global |
| 372 | // object no product surface could touch, denying the domain to everyone |
| 373 | // including its rightful owner. |
| 374 | func TestTeardownReleasesAnOrphanedClaimWithNoDomainRecord(t *testing.T) { |
| 375 | ctx := context.Background() |
| 376 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 377 | p := NewMemProvisioner("A") |
| 378 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 379 | d := testDeployment() |
| 380 | |
| 381 | p.FailNext["EnsureIdentity"] = errors.New("SES throttled") |
| 382 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "myapp", "mine.com", now); err == nil { |
| 383 | t.Fatal("expected the injected failure") |
| 384 | } |
| 385 | if claim, err := getDomainClaim(ctx, s, "mine.com"); err != nil || claim == nil { |
| 386 | t.Fatalf("claim missing before teardown: claim=%+v err=%v", claim, err) |
| 387 | } |
| 388 | if rec, _, err := GetDomain(ctx, s, "aaa", "mine.com"); err != nil || rec != nil { |
| 389 | t.Fatalf("a domain record exists despite the injected failure: %+v", rec) |
| 390 | } |
| 391 | |
| 392 | if err := Teardown(ctx, s, p, w, d, "aaa", "myapp"); err != nil { |
| 393 | t.Fatalf("teardown: %v", err) |
| 394 | } |
| 395 | if claim, err := getDomainClaim(ctx, s, "mine.com"); err != nil || claim != nil { |
| 396 | t.Fatalf("claim survived teardown despite having no record at all: %+v", claim) |
| 397 | } |
| 398 | |
| 399 | pStranger := NewMemProvisioner("B") |
| 400 | if _, _, err := EnsureDomain(ctx, s, pStranger, w, d, "bbb", "strangerapp", "mine.com", now); err != nil { |
| 401 | t.Fatalf("stranger should now be able to claim the orphaned domain: %v", err) |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | // The orphan-reconciliation pass must be scoped to claims with NO record — |
| 406 | // it must never touch a sibling app's live, fully-recorded domain, even |
| 407 | // though both belong to the same account being torn down. |
| 408 | func TestTeardownOrphanReconciliationLeavesRecordedClaimsAlone(t *testing.T) { |
| 409 | ctx := context.Background() |
| 410 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 411 | p := NewMemProvisioner("A") |
| 412 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 413 | d := testDeployment() |
| 414 | |
| 415 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app1", "live.com", now); err != nil { |
| 416 | t.Fatalf("app1 ensure: %v", err) |
| 417 | } |
| 418 | p.FailNext["EnsureIdentity"] = errors.New("SES throttled") |
| 419 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app2", "orphan.com", now); err == nil { |
| 420 | t.Fatal("expected the injected failure") |
| 421 | } |
| 422 | |
| 423 | if err := Teardown(ctx, s, p, w, d, "aaa", "app2"); err != nil { |
| 424 | t.Fatalf("teardown app2: %v", err) |
| 425 | } |
| 426 | |
| 427 | if claim, err := getDomainClaim(ctx, s, "live.com"); err != nil || claim == nil { |
| 428 | t.Fatalf("app1's live claim was released by app2's unrelated teardown: claim=%+v err=%v", claim, err) |
| 429 | } |
| 430 | if rec, _, err := GetDomain(ctx, s, "aaa", "live.com"); err != nil || rec == nil { |
| 431 | t.Fatalf("app1's live domain record was disturbed: %+v", rec) |
| 432 | } |
| 433 | if claim, err := getDomainClaim(ctx, s, "orphan.com"); err != nil || claim != nil { |
| 434 | t.Fatalf("app2's orphaned claim survived its own teardown: %+v", claim) |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | // ---- Round 2 (review follow-up): the sweep must not over-release, and -- |
| 439 | // ---- must not hand a stranger a live identity ------------------------- |
| 440 | |
| 441 | // TestTeardownOrphanSweepScopedToTheAttemptingApp (I1 item 1, round 2): an |
| 442 | // UNRELATED Teardown for a different app in the SAME account must never |
| 443 | // release another app's in-flight claim. The window this closes is not a |
| 444 | // microsecond race between goroutines — it is however long the member |
| 445 | // takes to retry after a throttle (a quota bump, a support ticket: hours), |
| 446 | // and nothing but the App attribution on the claim tells the sweep |
| 447 | // "abandoned" from "about to be retried". |
| 448 | func TestTeardownOrphanSweepScopedToTheAttemptingApp(t *testing.T) { |
| 449 | ctx := context.Background() |
| 450 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 451 | p := NewMemProvisioner("shared") |
| 452 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 453 | d := testDeployment() |
| 454 | |
| 455 | // app2 is ordinary and unrelated to the domain under test. |
| 456 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app2", "app2-domain.com", now); err != nil { |
| 457 | t.Fatalf("app2 setup: %v", err) |
| 458 | } |
| 459 | |
| 460 | // app3 leaves an orphan: claimed, no record — an ordinary retryable |
| 461 | // state, not an abandonment. |
| 462 | p.FailNext["EnsureIdentity"] = errors.New("SES throttled") |
| 463 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app3", "inflight.com", now); err == nil { |
| 464 | t.Fatal("expected the injected failure") |
| 465 | } |
| 466 | |
| 467 | // An UNRELATED Teardown, for a DIFFERENT app in the SAME account, must |
| 468 | // not touch app3's in-flight claim. |
| 469 | if err := Teardown(ctx, s, p, w, d, "aaa", "app2"); err != nil { |
| 470 | t.Fatalf("teardown app2: %v", err) |
| 471 | } |
| 472 | if claim, err := getDomainClaim(ctx, s, "inflight.com"); err != nil || claim == nil { |
| 473 | t.Fatalf("app2's teardown released app3's in-flight claim: claim=%+v err=%v", claim, err) |
| 474 | } |
| 475 | |
| 476 | // app3's OWN teardown (a genuine give-up) still releases it. |
| 477 | if err := Teardown(ctx, s, p, w, d, "aaa", "app3"); err != nil { |
| 478 | t.Fatalf("teardown app3: %v", err) |
| 479 | } |
| 480 | if claim, err := getDomainClaim(ctx, s, "inflight.com"); err != nil || claim != nil { |
| 481 | t.Fatalf("app3's own teardown did not release its orphan: %+v", claim) |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | // TestTeardownOrphanSweepDestroysTheIdentityBeforeReleasing (I1 item 2, |
| 486 | // round 2): if the crash that orphaned a claim landed AFTER EnsureIdentity |
| 487 | // minted a real SES identity but BEFORE any record was ever written, the |
| 488 | // sweep must destroy that identity before releasing — otherwise a stranger |
| 489 | // who claims the domain next inherits the victim's already-minted ARN and |
| 490 | // DKIM tokens with no DNS proof of their own, exactly what the review |
| 491 | // demonstrated (stranger inherited ARN=…identity/cust.com, |
| 492 | // DKIM=[tokeu1 tokeu2 tokeu3] — the victim's own tokens). |
| 493 | func TestTeardownOrphanSweepDestroysTheIdentityBeforeReleasing(t *testing.T) { |
| 494 | ctx := context.Background() |
| 495 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 496 | p := NewMemProvisioner("shared-aws-account") |
| 497 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 498 | d := testDeployment() // SNSTopicARN set, so EnsureConfigSet runs right after EnsureIdentity |
| 499 | |
| 500 | // Crash AFTER the identity is minted but BEFORE the record is ever |
| 501 | // written: EnsureConfigSet is the walk's very next step after |
| 502 | // EnsureIdentity, so failing it here reproduces exactly that ordering. |
| 503 | p.FailNext["EnsureConfigSet"] = errors.New("SES config set throttle") |
| 504 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "victimapp", "cust.com", now); err == nil { |
| 505 | t.Fatal("expected the injected failure") |
| 506 | } |
| 507 | victim, ok := p.Identities[identityKey(d.Region, "cust.com")] |
| 508 | if !ok { |
| 509 | t.Fatal("test setup: the identity was not actually minted before the injected failure") |
| 510 | } |
| 511 | victimTokens := append([]string(nil), victim.DKIMTokens...) |
| 512 | if rec, _, err := GetDomain(ctx, s, "aaa", "cust.com"); err != nil || rec != nil { |
| 513 | t.Fatalf("a domain record exists despite the injected failure: %+v", rec) |
| 514 | } |
| 515 | |
| 516 | if err := Teardown(ctx, s, p, w, d, "aaa", "victimapp"); err != nil { |
| 517 | t.Fatalf("teardown: %v", err) |
| 518 | } |
| 519 | if _, ok := p.Identities[identityKey(d.Region, "cust.com")]; ok { |
| 520 | t.Fatal("the orphan sweep released the claim without destroying the live identity") |
| 521 | } |
| 522 | |
| 523 | // A stranger claiming the released domain must get a FRESH identity — |
| 524 | // unverified, with NEW DKIM tokens — never the victim's ARN state or |
| 525 | // already-issued tokens. |
| 526 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "strangerapp", "cust.com", now) |
| 527 | if err != nil { |
| 528 | t.Fatalf("stranger should be able to claim after the sweep: %v", err) |
| 529 | } |
| 530 | got := rec.Regions[d.Region] |
| 531 | for _, tok := range got.DKIMTokens { |
| 532 | for _, old := range victimTokens { |
| 533 | if tok == old { |
| 534 | t.Fatalf("stranger inherited the victim's DKIM token %q", tok) |
| 535 | } |
| 536 | } |
| 537 | } |
| 538 | if got.Status != StatusPending { |
| 539 | t.Fatalf("stranger's identity status = %q, want pending (unverified) — a fresh identity, not the victim's verified one", got.Status) |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | // ---- C1 coverage gap (item 3, round 2): pin equality, not sub/superset -- |
| 544 | |
| 545 | // TestPlatformNameOwnedByRequiresExactLabelEquality pins platformNameOwnedBy's |
| 546 | // comparison as EQUALITY — neither a HasSuffix nor a HasPrefix substitution |
| 547 | // may pass this table. Also covers the bare-apex early return with an |
| 548 | // account name ("com") chosen specifically so the FALLBACK label-extraction |
| 549 | // logic would otherwise (wrongly) accept it — deleting the early-return |
| 550 | // guard is invisible against every OTHER case in this file, because the |
| 551 | // fallback happens to compute "com" from the apex and no other test |
| 552 | // account is named that. |
| 553 | func TestPlatformNameOwnedByRequiresExactLabelEquality(t *testing.T) { |
| 554 | d := testDeployment() // PlatformSuffix = "oncarlos.com" |
| 555 | cases := []struct { |
| 556 | name string |
| 557 | domain string |
| 558 | acct string |
| 559 | want bool |
| 560 | }{ |
| 561 | {"exact label match", "x.aaa.oncarlos.com", "aaa", true}, |
| 562 | {"caller is a strict prefix of the real label", "x.aaa.oncarlos.com", "aa", false}, |
| 563 | {"caller is a strict superset of the real label", "x.aa.oncarlos.com", "aaa", false}, |
| 564 | {"bare apex refused even for the account named after the fallback's own tail label", d.PlatformSuffix, "com", false}, |
| 565 | } |
| 566 | for _, c := range cases { |
| 567 | t.Run(c.name, func(t *testing.T) { |
| 568 | if got := platformNameOwnedBy(d, c.domain, c.acct); got != c.want { |
| 569 | t.Fatalf("platformNameOwnedBy(%q, %q) = %v, want %v", c.domain, c.acct, got, c.want) |
| 570 | } |
| 571 | }) |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | // TestTeardownOrphanSweepDoesNotCrossAccountsOnAMatchingAppName (round 3, |
| 576 | // review follow-up): the sweep's filter is `c.Account != acct || |
| 577 | // c.App != app` — BOTH halves ANDed together, not App alone. App names are |
| 578 | // short, common words, so two DIFFERENT accounts sharing one (e.g. both |
| 579 | // running an app called "web") is entirely plausible. If the Account half |
| 580 | // ever regressed out, the sweep would read GetDomain under the WRONG |
| 581 | // account, find nothing (domain records are account-keyed), conclude |
| 582 | // "orphan", and call DeleteIdentity on a domain ANOTHER account has |
| 583 | // live — destroying it — with releaseDomainClaim's own owner check only |
| 584 | // then quietly no-op'ing the claim removal itself. The destructive |
| 585 | // DeleteIdentity call happens BEFORE that guard, so the identity is gone |
| 586 | // regardless. This test sets up exactly that shape (two accounts, the |
| 587 | // SAME app name) and asserts account B's Teardown is a complete no-op |
| 588 | // against account A's orphan: claim intact, identity intact. |
| 589 | func TestTeardownOrphanSweepDoesNotCrossAccountsOnAMatchingAppName(t *testing.T) { |
| 590 | ctx := context.Background() |
| 591 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 592 | p := NewMemProvisioner("shared") |
| 593 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 594 | d := testDeployment() |
| 595 | const sameApp = "web" |
| 596 | |
| 597 | // Account A leaves an orphan under app "web": claimed, identity |
| 598 | // minted, no record — the crash-after-EnsureIdentity shape item 2 |
| 599 | // (round 2) closed for the SAME account/app; this test is about a |
| 600 | // DIFFERENT account reaching it. |
| 601 | p.FailNext["EnsureConfigSet"] = errors.New("SES config set throttle") |
| 602 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", sameApp, "orphaned-by-a.com", now); err == nil { |
| 603 | t.Fatal("expected the injected failure") |
| 604 | } |
| 605 | if _, ok := p.Identities[identityKey(d.Region, "orphaned-by-a.com")]; !ok { |
| 606 | t.Fatal("test setup: account A's identity was not actually minted") |
| 607 | } |
| 608 | if rec, _, err := GetDomain(ctx, s, "aaa", "orphaned-by-a.com"); err != nil || rec != nil { |
| 609 | t.Fatalf("a domain record exists despite the injected failure: %+v", rec) |
| 610 | } |
| 611 | |
| 612 | // Account B, a totally unrelated account, happens to name its own app |
| 613 | // "web" too and tears it down. This must be a complete no-op against |
| 614 | // account A's orphan. |
| 615 | if err := Teardown(ctx, s, p, w, d, "bbb", sameApp); err != nil { |
| 616 | t.Fatalf("teardown bbb/%s: %v", sameApp, err) |
| 617 | } |
| 618 | if claim, err := getDomainClaim(ctx, s, "orphaned-by-a.com"); err != nil || claim == nil || claim.Account != "aaa" { |
| 619 | t.Fatalf("account B's teardown disturbed account A's claim: claim=%+v err=%v", claim, err) |
| 620 | } |
| 621 | if _, ok := p.Identities[identityKey(d.Region, "orphaned-by-a.com")]; !ok { |
| 622 | t.Fatal("account B's teardown destroyed account A's LIVE identity on a matching app name") |
| 623 | } |
| 624 | } |
| 625 | |