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