| 1 | package mail |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/dnsrec" |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | ) |
| 12 | |
| 13 | func testDeployment() Deployment { |
| 14 | return Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com", |
| 15 | SNSTopicARN: "arn:aws:sns:eu-west-1:1:carlos-mail"} |
| 16 | } |
| 17 | |
| 18 | func TestDomainKindSplitsOnWhoOwnsTheZone(t *testing.T) { |
| 19 | d := testDeployment() |
| 20 | if got := DomainKindFor(d, "harbourmail.bag.oncarlos.com"); got != KindPlatform { |
| 21 | t.Fatalf("kind = %q, want platform", got) |
| 22 | } |
| 23 | if got := DomainKindFor(d, "harbourmail.com"); got != KindCustom { |
| 24 | t.Fatalf("kind = %q, want custom", got) |
| 25 | } |
| 26 | // A lookalike must NOT be treated as ours — suffix matching has to be |
| 27 | // label-aware, or "evil-oncarlos.com" would have CARLOS try to write |
| 28 | // DNS into a zone it does not own. |
| 29 | if got := DomainKindFor(d, "evil-oncarlos.com"); got != KindCustom { |
| 30 | t.Fatalf("kind for a lookalike = %q, want custom", got) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | // A platform domain is published for the member: CARLOS owns the zone, so |
| 35 | // the walk writes every record itself and the member does nothing. |
| 36 | func TestEnsureDomainPublishesPlatformDNS(t *testing.T) { |
| 37 | ctx := context.Background() |
| 38 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 39 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 40 | |
| 41 | rec, pending, err := EnsureDomain(ctx, s, p, w, testDeployment(), |
| 42 | "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now) |
| 43 | if err != nil { |
| 44 | t.Fatalf("EnsureDomain: %v", err) |
| 45 | } |
| 46 | if rec.Kind != KindPlatform { |
| 47 | t.Fatalf("kind = %q, want platform", rec.Kind) |
| 48 | } |
| 49 | if len(pending) != 0 { |
| 50 | t.Fatalf("a platform domain must leave nothing for the member to publish, got %d", len(pending)) |
| 51 | } |
| 52 | // 3 DKIM + MX + SPF + DMARC |
| 53 | if len(w.Records) != 6 { |
| 54 | t.Fatalf("published %d records, want 6: %+v", len(w.Records), w.Records) |
| 55 | } |
| 56 | st := rec.Regions["eu-west-1"] |
| 57 | if st.Status != StatusPending { |
| 58 | t.Fatalf("status = %q, want pending — DNS is written, SES has not converged", st.Status) |
| 59 | } |
| 60 | if st.MailFrom != "mail-eu-west-1.harbourmail.bag.oncarlos.com" { |
| 61 | t.Fatalf("MailFrom = %q", st.MailFrom) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // A custom domain is EMITTED for the customer: CARLOS cannot write their |
| 66 | // zone, so the same record set comes back for them to publish and nothing |
| 67 | // is written. |
| 68 | func TestEnsureDomainEmitsCustomRecordsAndWritesNothing(t *testing.T) { |
| 69 | ctx := context.Background() |
| 70 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 71 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 72 | |
| 73 | rec, pending, err := EnsureDomain(ctx, s, p, w, testDeployment(), |
| 74 | "bag", "harbourmail", "harbourmail.com", now) |
| 75 | if err != nil { |
| 76 | t.Fatalf("EnsureDomain: %v", err) |
| 77 | } |
| 78 | if rec.Kind != KindCustom { |
| 79 | t.Fatalf("kind = %q, want custom", rec.Kind) |
| 80 | } |
| 81 | if len(pending) != 6 { |
| 82 | t.Fatalf("emitted %d records for the customer, want 6", len(pending)) |
| 83 | } |
| 84 | if len(w.Records) != 0 { |
| 85 | t.Fatal("wrote into a zone CARLOS does not own") |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // Re-running is a no-op that returns the SAME tokens. This is the crash |
| 90 | // repair path: a walk that half-failed is fixed by running it again, and |
| 91 | // fresh tokens would orphan the DNS the first run published. |
| 92 | func TestEnsureDomainIsIdempotent(t *testing.T) { |
| 93 | ctx := context.Background() |
| 94 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 95 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 96 | d := testDeployment() |
| 97 | |
| 98 | first, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now) |
| 99 | if err != nil { |
| 100 | t.Fatalf("first: %v", err) |
| 101 | } |
| 102 | second, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now) |
| 103 | if err != nil { |
| 104 | t.Fatalf("second: %v", err) |
| 105 | } |
| 106 | f, sec := first.Regions["eu-west-1"], second.Regions["eu-west-1"] |
| 107 | if f.DKIMTokens[0] != sec.DKIMTokens[0] || f.IdentityARN != sec.IdentityARN { |
| 108 | t.Fatal("re-running changed the identity — published DNS would be orphaned") |
| 109 | } |
| 110 | if len(w.Records) != 6 { |
| 111 | t.Fatalf("re-running duplicated records: %d", len(w.Records)) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // A second app on one domain joins the apps set rather than re-creating the |
| 116 | // identity. This is the whole reason the record is account-scoped. |
| 117 | func TestEnsureDomainAddsASecondAppWithoutRecreating(t *testing.T) { |
| 118 | ctx := context.Background() |
| 119 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 120 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 121 | d := testDeployment() |
| 122 | |
| 123 | first, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "shared.com", now) |
| 124 | if err != nil { |
| 125 | t.Fatalf("first app: %v", err) |
| 126 | } |
| 127 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "marketing", "shared.com", now) |
| 128 | if err != nil { |
| 129 | t.Fatalf("second app: %v", err) |
| 130 | } |
| 131 | if len(rec.Apps) != 2 { |
| 132 | t.Fatalf("apps = %v, want both", rec.Apps) |
| 133 | } |
| 134 | if len(p.Identities) != 1 { |
| 135 | t.Fatalf("identities = %d, want 1 — a second app must join, not re-create", len(p.Identities)) |
| 136 | } |
| 137 | if first.Regions["eu-west-1"].IdentityARN != rec.Regions["eu-west-1"].IdentityARN { |
| 138 | t.Fatal("identity ARN changed between apps — the identity was re-created") |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // A half-failure after the identity exists but before DNS is published must |
| 143 | // be repaired by a re-run, not left stuck. |
| 144 | func TestEnsureDomainRepairsAHalfFailedPublish(t *testing.T) { |
| 145 | ctx := context.Background() |
| 146 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 147 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 148 | d := testDeployment() |
| 149 | |
| 150 | w.FailNext["EnsureRecord"] = errors.New("route53 down") |
| 151 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err == nil { |
| 152 | t.Fatal("EnsureDomain succeeded despite a DNS write failure") |
| 153 | } |
| 154 | // A failed publish must leave NO record — the record must never claim |
| 155 | // DNS that was not actually written. This is what pins the ordering: |
| 156 | // create-identity, then publish, then record, in that order. |
| 157 | if rec, _, err := GetDomain(ctx, s, "bag", "harbourmail.bag.oncarlos.com"); err != nil || rec != nil { |
| 158 | t.Fatalf("a failed publish left a record claiming DNS that is not there: %+v", rec) |
| 159 | } |
| 160 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil { |
| 161 | t.Fatalf("re-run did not repair: %v", err) |
| 162 | } |
| 163 | if len(w.Records) != 6 { |
| 164 | t.Fatalf("after repair, published %d records, want 6", len(w.Records)) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // A region already verified must not be knocked back to pending by a |
| 169 | // re-run — only a freshly-ensured region starts pending. StatusFailed is |
| 170 | // deliberately NOT preserved this way (a re-run is a fresh attempt), so |
| 171 | // this test targets verified specifically. |
| 172 | func TestEnsureDomainPreservesVerifiedStatus(t *testing.T) { |
| 173 | ctx := context.Background() |
| 174 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 175 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 176 | d := testDeployment() |
| 177 | |
| 178 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil { |
| 179 | t.Fatalf("first: %v", err) |
| 180 | } |
| 181 | |
| 182 | // Simulate SES having converged: mark the region verified directly on |
| 183 | // the persisted record, the way a status-check walk would. |
| 184 | got, etag, err := GetDomain(ctx, s, "bag", "harbourmail.bag.oncarlos.com") |
| 185 | if err != nil { |
| 186 | t.Fatalf("GetDomain: %v", err) |
| 187 | } |
| 188 | got.Regions["eu-west-1"].Status = StatusVerified |
| 189 | if _, err := PutDomain(ctx, s, "bag", got, etag); err != nil { |
| 190 | t.Fatalf("PutDomain: %v", err) |
| 191 | } |
| 192 | |
| 193 | second, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now) |
| 194 | if err != nil { |
| 195 | t.Fatalf("second: %v", err) |
| 196 | } |
| 197 | if got := second.Regions["eu-west-1"].Status; got != StatusVerified { |
| 198 | t.Fatalf("status = %q, want verified — a re-run must not knock back a converged region", got) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // A deployment with no SNS topic must not record a configuration set name |
| 203 | // for a set that was never created — a later send against a phantom set |
| 204 | // fails with "Configuration set does not exist" on every attempt. |
| 205 | func TestEnsureDomainLeavesConfigSetEmptyWithoutSNS(t *testing.T) { |
| 206 | ctx := context.Background() |
| 207 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 208 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 209 | d := testDeployment() |
| 210 | d.SNSTopicARN = "" |
| 211 | |
| 212 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now) |
| 213 | if err != nil { |
| 214 | t.Fatalf("EnsureDomain: %v", err) |
| 215 | } |
| 216 | if got := rec.Regions["eu-west-1"].ConfigSet; got != "" { |
| 217 | t.Fatalf("ConfigSet = %q, want empty — no SNS topic means no config set was created", got) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // C1 (review follow-up): recording a config set NAME in the domain record |
| 222 | // is not the same as SES actually emitting events for that identity — SES |
| 223 | // only does that for a send carrying a configuration set, and nothing |
| 224 | // before this task ever bound the identity to one. A domain ensured |
| 225 | // against a deployment WITH an SNS topic must have its identity's default |
| 226 | // configuration set stamped AT THE PROVISIONER, not just recorded in the |
| 227 | // bucket. |
| 228 | func TestEnsureDomainWithSNSStampsTheIdentitysConfigSet(t *testing.T) { |
| 229 | ctx := context.Background() |
| 230 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 231 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 232 | d := testDeployment() // carries an SNSTopicARN |
| 233 | |
| 234 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil { |
| 235 | t.Fatalf("EnsureDomain: %v", err) |
| 236 | } |
| 237 | id, ok := p.Identities[identityKey(d.Region, "harbourmail.bag.oncarlos.com")] |
| 238 | if !ok { |
| 239 | t.Fatal("no identity recorded at the provisioner") |
| 240 | } |
| 241 | if want := ConfigSetName("bag"); id.ConfigSet != want { |
| 242 | t.Fatalf("identity ConfigSet = %q, want %q — SetIdentityConfigSet was never called, so SES "+ |
| 243 | "would never emit an event for this identity and the daily cap/abuse pause would never fire", |
| 244 | id.ConfigSet, want) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // The symmetric SNS-less case: without a configuration set to bind to, |
| 249 | // SetIdentityConfigSet must not be called at all — calling it with an |
| 250 | // empty name would either error at a real backend or silently bind the |
| 251 | // identity to nothing. |
| 252 | func TestEnsureDomainWithoutSNSStampsNoIdentityConfigSet(t *testing.T) { |
| 253 | ctx := context.Background() |
| 254 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 255 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 256 | d := testDeployment() |
| 257 | d.SNSTopicARN = "" |
| 258 | |
| 259 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil { |
| 260 | t.Fatalf("EnsureDomain: %v", err) |
| 261 | } |
| 262 | id, ok := p.Identities[identityKey(d.Region, "harbourmail.bag.oncarlos.com")] |
| 263 | if !ok { |
| 264 | t.Fatal("no identity recorded at the provisioner") |
| 265 | } |
| 266 | if id.ConfigSet != "" { |
| 267 | t.Fatalf("identity ConfigSet = %q, want empty — no SNS topic means no config set to bind to", id.ConfigSet) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // acct reaches EnsureTenant — a real backend call — before anything checks |
| 272 | // it, unless validation gates the walk before that call. app is persisted |
| 273 | // into the record and later turned into an IAM principal name by other |
| 274 | // walks, so it must be validated too. |
| 275 | func TestEnsureDomainRefusesInvalidAccount(t *testing.T) { |
| 276 | ctx := context.Background() |
| 277 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 278 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 279 | |
| 280 | if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "../evil", "harbourmail", "harbourmail.bag.oncarlos.com", now); err == nil { |
| 281 | t.Fatal("EnsureDomain accepted an invalid account") |
| 282 | } |
| 283 | if p.Tenants["../evil"] || p.Tenants[TenantName("../evil")] { |
| 284 | t.Fatal("an invalid account reached EnsureTenant before validation") |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | // EnsureDomain must create the tenant under its PREFIXED name, never the |
| 289 | // bare account sqid — the SES tenant namespace is shared with other |
| 290 | // products in the same AWS account (TenantName's doc comment in policy.go |
| 291 | // has the live collision this guards against), so a bare sqid landing in |
| 292 | // p.Tenants would be the exact defect this closes. |
| 293 | func TestEnsureDomainCreatesPrefixedTenant(t *testing.T) { |
| 294 | ctx := context.Background() |
| 295 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 296 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 297 | |
| 298 | if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), |
| 299 | "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil { |
| 300 | t.Fatalf("EnsureDomain: %v", err) |
| 301 | } |
| 302 | if !p.Tenants["carlos-bag"] { |
| 303 | t.Fatal("EnsureDomain did not create the prefixed tenant carlos-bag") |
| 304 | } |
| 305 | if p.Tenants["bag"] { |
| 306 | t.Fatal("EnsureDomain created a bare-sqid tenant — this collides with another product's namespace") |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | func TestEnsureDomainRefusesInvalidApp(t *testing.T) { |
| 311 | ctx := context.Background() |
| 312 | s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| 313 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 314 | |
| 315 | if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "bag", "../evil", "harbourmail.bag.oncarlos.com", now); err == nil { |
| 316 | t.Fatal("EnsureDomain accepted an invalid app") |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | // failAfterN wraps a Writer and fails the (n+1)th EnsureRecord, so a |
| 321 | // PARTIAL publish can be exercised — MemWriter.FailNext is one-shot on |
| 322 | // the first call, which is the least realistic crash point for this walk |
| 323 | // (three published, then a crash, is the realistic one). After the one |
| 324 | // failure it behaves like the wrapped Writer for good, matching how a |
| 325 | // transient DNS-provider failure would look on a retry. |
| 326 | type failAfterN struct { |
| 327 | dnsrec.Writer |
| 328 | n int |
| 329 | err error |
| 330 | failed bool |
| 331 | } |
| 332 | |
| 333 | func (f *failAfterN) EnsureRecord(ctx context.Context, r dnsrec.Record) error { |
| 334 | if !f.failed && f.n <= 0 { |
| 335 | f.failed = true |
| 336 | return f.err |
| 337 | } |
| 338 | if !f.failed { |
| 339 | f.n-- |
| 340 | } |
| 341 | return f.Writer.EnsureRecord(ctx, r) |
| 342 | } |
| 343 | |
| 344 | // A crash after SOME records are published but before all six is the |
| 345 | // realistic failure mode this walk must survive. A re-run must converge to |
| 346 | // the full set with no duplicates, and the half-published attempt must not |
| 347 | // have persisted a record claiming DNS that is not fully there. |
| 348 | func TestEnsureDomainRepairsAPartialPublish(t *testing.T) { |
| 349 | ctx := context.Background() |
| 350 | s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| 351 | inner := dnsrec.NewMemWriter() |
| 352 | w := &failAfterN{Writer: inner, n: 3, err: errors.New("route53 down mid-publish")} |
| 353 | now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| 354 | d := testDeployment() |
| 355 | |
| 356 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err == nil { |
| 357 | t.Fatal("EnsureDomain succeeded despite a mid-publish failure") |
| 358 | } |
| 359 | if len(inner.Records) != 3 { |
| 360 | t.Fatalf("published %d records before the failure, want 3", len(inner.Records)) |
| 361 | } |
| 362 | if rec, _, err := GetDomain(ctx, s, "bag", "harbourmail.bag.oncarlos.com"); err != nil || rec != nil { |
| 363 | t.Fatalf("a partial publish left a record claiming DNS that is not fully there: %+v", rec) |
| 364 | } |
| 365 | |
| 366 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil { |
| 367 | t.Fatalf("re-run did not repair: %v", err) |
| 368 | } |
| 369 | if len(inner.Records) != 6 { |
| 370 | t.Fatalf("after repair, published %d records, want 6 with no duplicates", len(inner.Records)) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | // Step 2 associates each account's EXISTING tenant with its identities and |
| 375 | // its configuration set (design §7, "Associations are wired in step 2, not |
| 376 | // here"). Associating a resource does not modify it and changes no |
| 377 | // behaviour on its own: no send carries X-SES-TENANT yet. It is done now so |
| 378 | // that step 3 can rely on it, and because SES validates the association on |
| 379 | // every tenant-attributed send. |
| 380 | func TestEnsureDomainAssociatesTheIdentityAndTheSetWithTheAccountsTenant(t *testing.T) { |
| 381 | ctx := context.Background() |
| 382 | s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| 383 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 384 | if _, err := Declare(ctx, s, "bag", "harbourmail", "HARBOURMAIL_SMTP", |
| 385 | "noreply@harbourmail.bag.oncarlos.com", "owner@example.test", now); err != nil { |
| 386 | t.Fatal(err) |
| 387 | } |
| 388 | rec, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| 389 | "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now) |
| 390 | if err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | tenant := TenantName("bag") |
| 394 | identity := rec.Regions["eu-west-1"].IdentityARN |
| 395 | set, ok := ConfigSetARN(identity, ConfigSetName("bag")) |
| 396 | if !ok { |
| 397 | t.Fatalf("no configuration-set ARN composed from %q", identity) |
| 398 | } |
| 399 | for _, arn := range []string{identity, set} { |
| 400 | if !p.TenantResources[tenant][arn] { |
| 401 | t.Errorf("%s is not associated with %s: %v", arn, tenant, p.TenantResources[tenant]) |
| 402 | } |
| 403 | } |
| 404 | // A re-run associates the same pair again without error: every verb on |
| 405 | // this seam is idempotent, which is what makes the walk re-runnable. |
| 406 | if _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| 407 | "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil { |
| 408 | t.Fatalf("re-run: %v", err) |
| 409 | } |
| 410 | |
| 411 | // An identity cannot be deleted while an association exists (design §3), |
| 412 | // so teardown removes the association first. MemProvisioner models that |
| 413 | // refusal, because without it the first real teardown would fail. |
| 414 | if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail"); err != nil { |
| 415 | t.Fatalf("Teardown: %v", err) |
| 416 | } |
| 417 | if p.TenantResources[tenant][identity] { |
| 418 | t.Error("the identity association survived teardown, so the identity could never be deleted") |
| 419 | } |
| 420 | // The configuration set is per ACCOUNT and outlives any one app, so its |
| 421 | // association is never removed by an app's teardown. |
| 422 | if !p.TenantResources[tenant][set] { |
| 423 | t.Error("an app's teardown removed the account's configuration-set association") |
| 424 | } |
| 425 | } |
| 426 | |