| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "net/url" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/appconfig" |
| 15 | "github.com/carlosframework/platform/internal/dnsrec" |
| 16 | "github.com/carlosframework/platform/internal/domains" |
| 17 | "github.com/carlosframework/platform/internal/doorkey" |
| 18 | "github.com/carlosframework/platform/internal/mail" |
| 19 | "github.com/carlosframework/platform/internal/objstore" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | // deletableFixture signs an owner in and gives them a SECOND account — one that |
| 24 | // is not ops, holds no apps, and therefore can actually be deleted. Minted |
| 25 | // through the bucket and materialized, which is how every real account arrives. |
| 26 | func deletableFixture(t *testing.T) (*consoleFixture, *http.Client, string, string) { |
| 27 | t.Helper() |
| 28 | fx := newConsoleFixture(t) |
| 29 | c := newBrowser(t) |
| 30 | signUp(t, fx, c, "code-delete") |
| 31 | _, sqid, err := domains.MintAccount(context.Background(), fx.rel, "Testco", "paul@keymail.dev", time.Now()) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | if n, err := MaterializeAccounts(context.Background(), fx.rel, fx.st); err != nil || n != 1 { |
| 36 | t.Fatalf("materialize = %d %v, want 1", n, err) |
| 37 | } |
| 38 | return fx, c, sessionToken(t, fx, c), sqid |
| 39 | } |
| 40 | |
| 41 | // A deleted account stays deleted across a console restart. |
| 42 | // |
| 43 | // This is the failure the tombstone exists for: account identity lives in the |
| 44 | // bucket, and every console start materializes it back, so a deletion that only |
| 45 | // emptied the console's own tables was undone by the next deploy — silently, |
| 46 | // and complete with the owner membership the manifest names. |
| 47 | func TestDeletedAccountDoesNotComeBack(t *testing.T) { |
| 48 | fx, c, csrf, sqid := deletableFixture(t) |
| 49 | ctx := context.Background() |
| 50 | |
| 51 | status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete", |
| 52 | url.Values{"csrf": {csrf}, "confirm": {"Testco"}}) |
| 53 | if status != http.StatusSeeOther { |
| 54 | t.Fatalf("delete = %d:\n%s", status, body) |
| 55 | } |
| 56 | if a, _ := fx.st.AccountBySqid(sqid); a != nil { |
| 57 | t.Fatal("the account survived its own deletion") |
| 58 | } |
| 59 | // Invisible, but not forgotten: the console must remember the tombstone |
| 60 | // (see DeleteAccount) or the next materialize pass could be tricked into |
| 61 | // re-birthing it. |
| 62 | if a, deleted, err := fx.st.AccountAnyBySqid(sqid); err != nil || a == nil || !deleted { |
| 63 | t.Fatalf("AccountAnyBySqid after delete = %+v %v %v, want a marked row", a, deleted, err) |
| 64 | } |
| 65 | |
| 66 | // The next console start. Nothing may bring it back. |
| 67 | if n, err := MaterializeAccounts(ctx, fx.rel, fx.st); err != nil || n != 0 { |
| 68 | t.Fatalf("materialize after deletion = %d %v, want 0", n, err) |
| 69 | } |
| 70 | if a, _ := fx.st.AccountBySqid(sqid); a != nil { |
| 71 | t.Fatal("the deleted account was materialized back out of the bucket") |
| 72 | } |
| 73 | // And no membership was resurrected with it — the manifest's owner field is |
| 74 | // cleared by the tombstone precisely so a consumer that has never heard of |
| 75 | // deleted_at cannot hand the account back to a former owner. |
| 76 | m := readManifest(t, fx.rel, sqid) |
| 77 | if !m.Deleted() { |
| 78 | t.Fatal("the manifest was not tombstoned") |
| 79 | } |
| 80 | if m.Owner != "" { |
| 81 | t.Fatalf("tombstone kept owner %q", m.Owner) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // The id a deleted account held is never handed to another account. Sqids ride |
| 86 | // in hostnames and in Certificate Transparency; reissuing one would give a new |
| 87 | // account a name the world already associates with somebody else. |
| 88 | func TestTombstonedAccountKeepsItsIDSpent(t *testing.T) { |
| 89 | fx, c, csrf, sqid := deletableFixture(t) |
| 90 | ctx := context.Background() |
| 91 | before, err := domains.NextAccountID(ctx, fx.rel) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | |
| 96 | if status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete", |
| 97 | url.Values{"csrf": {csrf}, "confirm": {"Testco"}}); status != http.StatusSeeOther { |
| 98 | t.Fatalf("delete = %d:\n%s", status, body) |
| 99 | } |
| 100 | |
| 101 | after, err := domains.NextAccountID(ctx, fx.rel) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | if after != before { |
| 106 | t.Fatalf("next account id moved from %d to %d after a deletion — the retired id is being reused", before, after) |
| 107 | } |
| 108 | _, next, err := domains.MintAccount(ctx, fx.rel, "Nextco", "paul@keymail.dev", time.Now()) |
| 109 | if err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if next == sqid { |
| 113 | t.Fatalf("a new account was minted onto the retired sqid %q", sqid) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // A bucket that cannot be written is a deletion that does not happen. Refusing |
| 118 | // is the only honest answer: a console-only deletion looks like it worked and |
| 119 | // then undoes itself. |
| 120 | func TestDeletionRefusedWhenTheBucketIsUnreachable(t *testing.T) { |
| 121 | fx, c, csrf, sqid := deletableFixture(t) |
| 122 | fx.srv.cfg.Releases = nil |
| 123 | |
| 124 | status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete", |
| 125 | url.Values{"csrf": {csrf}, "confirm": {"Testco"}}) |
| 126 | if status != http.StatusSeeOther { |
| 127 | t.Fatalf("delete = %d:\n%s", status, body) |
| 128 | } |
| 129 | if a, _ := fx.st.AccountBySqid(sqid); a == nil { |
| 130 | t.Fatal("the account was deleted locally even though the bucket could not be told") |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func readManifest(t *testing.T, s release.Store, sqid string) domains.AccountManifest { |
| 135 | t.Helper() |
| 136 | body, _, err := s.Get(context.Background(), AccountManifestKey(sqid)) |
| 137 | if err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | var m domains.AccountManifest |
| 141 | if err := json.Unmarshal(body, &m); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | return m |
| 145 | } |
| 146 | |
| 147 | // ---- the Trash page --------------------------------------------------------- |
| 148 | // |
| 149 | // Restore is the undo button — any member, no passkey. "Delete now" is the |
| 150 | // ceremonial end of the feature: owner, the app's own name typed out, a |
| 151 | // passkey, and only then is anything destroyed. The tests below are mostly |
| 152 | // about what must NOT happen, because every refusal here is the difference |
| 153 | // between a recoverable mistake and an unrecoverable one. |
| 154 | |
| 155 | // trashKass puts the fixture's app in the Trash the way every other door |
| 156 | // does: the bucket marker first, then the row, both carrying one clock. |
| 157 | func trashKass(t *testing.T, fx *consoleFixture, actor string, now time.Time) { |
| 158 | t.Helper() |
| 159 | ctx := context.Background() |
| 160 | if err := release.TrashApp(ctx, fx.rel, fx.sqid, "kass", actor, now); err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | if err := fx.st.TrashAppRow(OpsAccountID, "kass", now); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // trashPageFixture is the owner signed in with kass already in the Trash. |
| 169 | func trashPageFixture(t *testing.T) (*consoleFixture, *http.Client, string) { |
| 170 | t.Helper() |
| 171 | fx := newConsoleFixture(t) |
| 172 | c := newBrowser(t) |
| 173 | signUp(t, fx, c, "code-trash") |
| 174 | trashKass(t, fx, "paul@keymail.dev", time.Now()) |
| 175 | return fx, c, sessionToken(t, fx, c) |
| 176 | } |
| 177 | |
| 178 | // appKeys is everything the bucket still holds for an app — the yardstick for |
| 179 | // "nothing was destroyed" and for "everything was". |
| 180 | func appKeys(t *testing.T, fx *consoleFixture, app string) []string { |
| 181 | t.Helper() |
| 182 | keys, err := fx.rel.List(context.Background(), "apps/"+fx.sqid+"/"+app+"/") |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | return keys |
| 187 | } |
| 188 | |
| 189 | // The page itself: the trashed app, who trashed it, when the platform will |
| 190 | // destroy it on its own, and the one thing a restore does not bring back. |
| 191 | func TestTrashPageListsWhatIsInIt(t *testing.T) { |
| 192 | fx, c, _ := trashPageFixture(t) |
| 193 | |
| 194 | status, _, body := get(t, c, fx.u("/trash")) |
| 195 | if status != http.StatusOK { |
| 196 | t.Fatalf("trash page = %d:\n%s", status, body) |
| 197 | } |
| 198 | tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, "kass") |
| 199 | if err != nil || tomb == nil { |
| 200 | t.Fatalf("marker = %+v %v", tomb, err) |
| 201 | } |
| 202 | purgesAt := tomb.DeletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02 15:04 UTC") |
| 203 | for _, want := range []string{"kass", "paul@keymail.dev", purgesAt, "carlos add"} { |
| 204 | if !strings.Contains(body, want) { |
| 205 | t.Fatalf("trash page missing %q in:\n%s", want, body) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // The account home links here — and only while there is something to see. |
| 210 | _, _, home := get(t, c, fx.u("/")) |
| 211 | if !strings.Contains(home, "Trash (1)") { |
| 212 | t.Fatalf("account home has no Trash link:\n%s", home) |
| 213 | } |
| 214 | if strings.Contains(home, `<a href="`+fx.path("")+`/apps/kass">`) { |
| 215 | t.Fatal("a trashed app is still listed among the live ones") |
| 216 | } |
| 217 | if err := fx.st.RestoreAppRow(OpsAccountID, "kass"); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | if _, _, home := get(t, c, fx.u("/")); strings.Contains(home, "Trash (") { |
| 221 | t.Fatalf("an empty trash still advertises itself:\n%s", home) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // The page renders when the bucket cannot be read: the actor is unknown, the |
| 226 | // row's own date stands in, and nothing about the page is destructive. Only |
| 227 | // destruction requires believing the marker. |
| 228 | func TestTrashPageSurvivesAnUnreadableMarker(t *testing.T) { |
| 229 | fx, c, _ := trashPageFixture(t) |
| 230 | ctx := context.Background() |
| 231 | key := release.AppTombstoneKey(fx.sqid, "kass") |
| 232 | if err := fx.rel.Delete(ctx, key); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | if _, err := fx.rel.Put(ctx, key, []byte("{ not json"), ""); err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | |
| 239 | status, _, body := get(t, c, fx.u("/trash")) |
| 240 | if status != http.StatusOK { |
| 241 | t.Fatalf("trash page with an unreadable marker = %d:\n%s", status, body) |
| 242 | } |
| 243 | if !strings.Contains(body, "kass") || !strings.Contains(body, "<td>—</td>") { |
| 244 | t.Fatalf("trash page did not degrade to an em-dash actor:\n%s", body) |
| 245 | } |
| 246 | // The ROW's own date stands in, so the page still says when this will be |
| 247 | // destroyed rather than showing the zero time. |
| 248 | rows, err := fx.st.TrashedAppsIn(OpsAccountID) |
| 249 | if err != nil || len(rows) != 1 { |
| 250 | t.Fatalf("TrashedAppsIn = %+v %v", rows, err) |
| 251 | } |
| 252 | fromRow := rows[0].DeletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02 15:04 UTC") |
| 253 | if !strings.Contains(body, fromRow) { |
| 254 | t.Fatalf("trash page did not fall back to the row's date (%s):\n%s", fromRow, body) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // Neither the page nor either action may tell a stranger this account exists. |
| 259 | func TestTrashIsNotAnOracle(t *testing.T) { |
| 260 | fx, _, _ := trashPageFixture(t) |
| 261 | stranger := newBrowser(t) |
| 262 | fx.setUser("stranger@keymail.dev") |
| 263 | signUp(t, fx, stranger, "code-trash-stranger") |
| 264 | csrf := sessionToken(t, fx, stranger) |
| 265 | |
| 266 | _, _, missingGet := get(t, stranger, fx.ts.URL+"/a/zzzzz/trash") |
| 267 | if _, _, body := get(t, stranger, fx.u("/trash")); body != missingGet { |
| 268 | t.Fatalf("the trash page answers a non-member differently from a nonexistent account:\n%q\nvs\n%q", |
| 269 | body, missingGet) |
| 270 | } |
| 271 | _, _, missingPost := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/trash/restore", |
| 272 | url.Values{"csrf": {csrf}, "app": {"kass"}}) |
| 273 | for _, path := range []string{"/trash/restore", "/trash/delete"} { |
| 274 | status, loc, body := postForm(t, stranger, fx.u(path), |
| 275 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 276 | if status != http.StatusNotFound { |
| 277 | t.Fatalf("%s as a non-member = %d -> %q, want 404", path, status, loc) |
| 278 | } |
| 279 | if body != missingPost { |
| 280 | t.Fatalf("%s as a non-member answers differently from a nonexistent account:\n%q\nvs\n%q", |
| 281 | path, body, missingPost) |
| 282 | } |
| 283 | } |
| 284 | if len(appKeys(t, fx, "kass")) == 0 { |
| 285 | t.Fatal("a stranger's post destroyed something") |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | // Restore is cheap: any member, no passkey, and it says what it did not do. |
| 290 | func TestRestoreNeedsNoPasskey(t *testing.T) { |
| 291 | fx, _, _ := trashPageFixture(t) |
| 292 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | fx.setUser("sam@keymail.dev") |
| 296 | c := newBrowser(t) |
| 297 | signUp(t, fx, c, "code-sam-restore") |
| 298 | csrf := sessionToken(t, fx, c) |
| 299 | |
| 300 | // postForm, deliberately: a step-up redirect here would be a bug. |
| 301 | status, loc, body := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}) |
| 302 | if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 303 | t.Fatalf("restore = %d -> %q, want a plain redirect with no passkey:\n%s", status, loc, body) |
| 304 | } |
| 305 | if tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, "kass"); err != nil || tomb != nil { |
| 306 | t.Fatalf("the trash marker survived the restore: %+v %v", tomb, err) |
| 307 | } |
| 308 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 309 | t.Fatalf("HasAppIn after restore = %v %v, want the row live again", has, err) |
| 310 | } |
| 311 | // The one thing restore does NOT do, said where the person can read it. |
| 312 | _, _, page := get(t, c, fx.ts.URL+loc) |
| 313 | if !strings.Contains(page, "carlos add") { |
| 314 | t.Fatalf("the restore flash does not mention that routes need carlos add:\n%s", page) |
| 315 | } |
| 316 | auditedBy(t, fx, "kass", "restore", "sam@keymail.dev", "kass") |
| 317 | } |
| 318 | |
| 319 | // A member who is not an owner meets the 404, before a passkey redirect could |
| 320 | // confirm anything. |
| 321 | func TestDeleteNowIsOwnerOnly(t *testing.T) { |
| 322 | fx, _, _ := trashPageFixture(t) |
| 323 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | fx.setUser("sam@keymail.dev") |
| 327 | c := newBrowser(t) |
| 328 | signUp(t, fx, c, "code-sam-purge") |
| 329 | csrf := sessionToken(t, fx, c) |
| 330 | |
| 331 | before := appKeys(t, fx, "kass") |
| 332 | status, _, memberBody := postForm(t, c, fx.u("/trash/delete"), |
| 333 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 334 | if status != http.StatusNotFound { |
| 335 | t.Fatalf("member deleting permanently = %d, want 404", status) |
| 336 | } |
| 337 | strangerBody := strangerRefusal(t, fx, "code-zoe-purge", fx.u("/trash/delete"), |
| 338 | url.Values{"app": {"kass"}, "confirm": {"kass"}}) |
| 339 | if memberBody != strangerBody { |
| 340 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 341 | } |
| 342 | if len(appKeys(t, fx, "kass")) != len(before) { |
| 343 | t.Fatal("a member's refused delete destroyed objects") |
| 344 | } |
| 345 | if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 346 | t.Fatalf("HasTrashedAppIn = %v %v, want the row untouched", has, err) |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | // The typed name is checked BEFORE the passkey, so a slip costs nothing but a |
| 351 | // retry — and destroys nothing. |
| 352 | func TestDeleteNowRequiresTheAppsOwnName(t *testing.T) { |
| 353 | fx, c, csrf := trashPageFixture(t) |
| 354 | before := appKeys(t, fx, "kass") |
| 355 | |
| 356 | status, loc, body := postForm(t, c, fx.u("/trash/delete"), |
| 357 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kas"}}) |
| 358 | if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 359 | t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body) |
| 360 | } |
| 361 | if len(appKeys(t, fx, "kass")) != len(before) { |
| 362 | t.Fatal("a mistyped delete destroyed objects") |
| 363 | } |
| 364 | if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 365 | t.Fatalf("HasTrashedAppIn = %v %v, want the row untouched", has, err) |
| 366 | } |
| 367 | var stashed int |
| 368 | fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) |
| 369 | if stashed != 0 { |
| 370 | t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway") |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | // The whole ceremony, and what is left afterwards: nothing in the bucket, no |
| 375 | // row, and a name anyone in the account may claim again. |
| 376 | func TestDeleteNowDestroysEverythingAndFreesTheName(t *testing.T) { |
| 377 | fx, c, csrf := trashPageFixture(t) |
| 378 | |
| 379 | // Without the passkey, nothing moves. |
| 380 | status, loc, _ := postForm(t, c, fx.u("/trash/delete"), |
| 381 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 382 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 383 | t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc) |
| 384 | } |
| 385 | if len(appKeys(t, fx, "kass")) == 0 { |
| 386 | t.Fatal("objects were destroyed before the passkey confirmation") |
| 387 | } |
| 388 | |
| 389 | // Purge does not announce: by the time it runs, kass's routes and minting |
| 390 | // were already stopped at trash time — there is nothing left a box's fast |
| 391 | // pass converges on (see handlePurgeApp, ownership.go). |
| 392 | before := serial(t, fx) |
| 393 | |
| 394 | // With it, the held action resumes and lands. |
| 395 | status, loc, body := completeStepUp(t, fx, c, loc) |
| 396 | if status != http.StatusSeeOther { |
| 397 | t.Fatalf("resumed delete = %d:\n%s", status, body) |
| 398 | } |
| 399 | if keys := appKeys(t, fx, "kass"); len(keys) != 0 { |
| 400 | t.Fatalf("the bucket still holds %v", keys) |
| 401 | } |
| 402 | if got := serial(t, fx); got != before { |
| 403 | t.Fatalf("serial after a purge = %d, want unchanged (%d) — purge announces nothing", got, before) |
| 404 | } |
| 405 | if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || has { |
| 406 | t.Fatalf("HasTrashedAppIn after purge = %v %v, want the row gone", has, err) |
| 407 | } |
| 408 | created, err := fx.st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()) |
| 409 | if err != nil || !created { |
| 410 | t.Fatalf("re-claiming the purged name = %v %v, want it free", created, err) |
| 411 | } |
| 412 | auditedBy(t, fx, "kass", "purge", "paul@keymail.dev", "kass") |
| 413 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "free to claim again") { |
| 414 | t.Fatalf("the purge flash does not say the name is free:\n%s", page) |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | // Delete now takes the app's ledgers and door keys with it (#219 items 1 |
| 419 | // and 2): both are keyed by the name the purge frees, outside apps/. The |
| 420 | // door-key record is republished at once, empty, rather than at the next |
| 421 | // 12-hourly re-sign. |
| 422 | func TestDeleteNowSweepsLedgersAndDoorKeys(t *testing.T) { |
| 423 | fx, c, csrf := trashPageFixture(t) |
| 424 | if _, err := fx.st.DoorKeyInsert(OpsAccountID, "kass", doorkey.Entry{ID: "0123456789abcdef", SHA256: strings.Repeat("a", 64)}); err != nil { |
| 425 | t.Fatal(err) |
| 426 | } |
| 427 | ledgerKey := release.LedgerAppPrefix(fx.sqid, "kass") + "ledgers.json" |
| 428 | if _, err := fx.rel.Put(context.Background(), ledgerKey, []byte(`{"schema":1,"public":true}`), ""); err != nil { |
| 429 | t.Fatal(err) |
| 430 | } |
| 431 | status, _, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 432 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 433 | if status != http.StatusSeeOther { |
| 434 | t.Fatalf("delete = %d:\n%s", status, body) |
| 435 | } |
| 436 | if keys, err := fx.rel.List(context.Background(), release.LedgerAppPrefix(fx.sqid, "kass")); err != nil || len(keys) != 0 { |
| 437 | t.Fatalf("ledgers after purge = %v %v, want none", keys, err) |
| 438 | } |
| 439 | wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey(fx.sqid, "kass")) |
| 440 | if err != nil { |
| 441 | t.Fatalf("door-key record after purge: %v", err) |
| 442 | } |
| 443 | rec, err := doorkey.Verify(fx.pub, wire, time.Now()) |
| 444 | if err != nil { |
| 445 | t.Fatal(err) |
| 446 | } |
| 447 | if len(rec.Keys) != 0 { |
| 448 | t.Fatalf("published door keys after purge = %+v, want none", rec.Keys) |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | // The production flag is the deletion protection, all the way to the last |
| 453 | // door: a trashed app that is flagged production is not destroyed, however |
| 454 | // much ceremony is performed at it. |
| 455 | func TestDeleteNowRefusesAProductionApp(t *testing.T) { |
| 456 | fx := newConsoleFixture(t) |
| 457 | ctx := context.Background() |
| 458 | // Flagged BEFORE the trash, bucket-direct: the API door refuses to trash a |
| 459 | // production app at all, so this is the state that can only arrive by a |
| 460 | // flag set after the fact — the impossible one the purge path still guards. |
| 461 | if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { |
| 462 | t.Fatal(err) |
| 463 | } |
| 464 | c := newBrowser(t) |
| 465 | signUp(t, fx, c, "code-trash-prod") |
| 466 | trashKass(t, fx, "paul@keymail.dev", time.Now()) |
| 467 | csrf := sessionToken(t, fx, c) |
| 468 | before := appKeys(t, fx, "kass") |
| 469 | |
| 470 | status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 471 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 472 | if status != http.StatusSeeOther { |
| 473 | t.Fatalf("delete of a production app = %d:\n%s", status, body) |
| 474 | } |
| 475 | if keys := appKeys(t, fx, "kass"); len(keys) != len(before) { |
| 476 | t.Fatalf("a refused delete destroyed objects: %v -> %v", before, keys) |
| 477 | } |
| 478 | if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 479 | t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err) |
| 480 | } |
| 481 | // The same sentence the CLI's 409 gives, so an operator meeting both hears |
| 482 | // one instruction. |
| 483 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "the off-switch on the app page") { |
| 484 | t.Fatalf("the refusal does not name the off-switch on the app page:\n%s", page) |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | // ---- purge and the object store --------------------------------------------- |
| 489 | // |
| 490 | // An app's object store is the one part of it that lives OUTSIDE the deployment |
| 491 | // bucket — a real bucket, in a real cloud account, with a real IAM principal |
| 492 | // holding a live key. Sweeping apps/<acct>/<app>/ without tearing that down |
| 493 | // first would leave a paid, credentialed estate that no record names, which is |
| 494 | // why the box-side auto-purge refuses outright (release.ErrStoreProvisioned) |
| 495 | // and why this door — the only one with backend authority — goes first. |
| 496 | |
| 497 | // armStoreBackend gives the fixture's console an in-memory storage backend and |
| 498 | // a deployment that mints carlos-<acct>-<app> buckets, which is what makes it a |
| 499 | // console that may destroy one. |
| 500 | func armStoreBackend(t *testing.T, fx *consoleFixture) *objstore.MemProvisioner { |
| 501 | t.Helper() |
| 502 | fake := objstore.NewMemProvisioner("eu-west-1") |
| 503 | fx.srv.cfg.StoreProvisioner = fake |
| 504 | fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"} |
| 505 | return fake |
| 506 | } |
| 507 | |
| 508 | // seedStore builds the estate a completed grant leaves behind — a bucket with a |
| 509 | // customer object in it, the principal holding its key, and the record naming |
| 510 | // both — hand-written because the door that builds it is an operator API away |
| 511 | // (apistore.go). crashed=true is the shape a grant that died before its step-6 |
| 512 | // Put leaves: bucket, user and key all real, `provisioned` never recorded. It |
| 513 | // returns the bucket name, which is also the word this app's destroy door makes |
| 514 | // a person type. |
| 515 | func seedStore(t *testing.T, fx *consoleFixture, fake *objstore.MemProvisioner, app string, crashed bool) string { |
| 516 | t.Helper() |
| 517 | ctx := context.Background() |
| 518 | bucket := objstore.MintedBucket(fx.srv.cfg.StoreDeployment, fx.sqid, app) |
| 519 | if _, err := fake.EnsureBucket(ctx, bucket); err != nil { |
| 520 | t.Fatal(err) |
| 521 | } |
| 522 | fake.Buckets[bucket]["customer/data.bin"] = []byte("the customer's objects") |
| 523 | user := objstore.UserName(fx.sqid, app) |
| 524 | arn, err := fake.EnsureUser(ctx, user, objstore.UserPath, "arn:aws:s3:::"+bucket, nil) |
| 525 | if err != nil { |
| 526 | t.Fatal(err) |
| 527 | } |
| 528 | keyID, _, err := fake.MintKey(ctx, user) |
| 529 | if err != nil { |
| 530 | t.Fatal(err) |
| 531 | } |
| 532 | now := time.Now() |
| 533 | rec := &objstore.Record{ |
| 534 | Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: now, By: "paul@keymail.dev"}, |
| 535 | Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, At: now, By: "paul@keymail.dev"}, |
| 536 | } |
| 537 | if !crashed { |
| 538 | rec.Provisioned = &objstore.Provisioned{Bucket: bucket, Region: "eu-west-1", IAMUser: user, |
| 539 | UserARN: arn, KeyID: keyID, At: now} |
| 540 | // The delivered half: the coordinates a grant writes into the app's |
| 541 | // config. Teardown unsets these, so their presence is what "nothing was |
| 542 | // destroyed" means on the config side, and their absence is half of what |
| 543 | // a completed teardown means. |
| 544 | bucketName, regionName, _, _, _ := objstore.EnvNames(objstore.DefaultEnvPrefix) |
| 545 | if _, err := appconfig.Apply(ctx, fx.rel, fx.sqid, app, map[string]appconfig.Var{ |
| 546 | bucketName: {Kind: "env", Value: bucket}, |
| 547 | regionName: {Kind: "env", Value: "eu-west-1"}, |
| 548 | }, nil); err != nil { |
| 549 | t.Fatal(err) |
| 550 | } |
| 551 | } |
| 552 | putRecord(t, fx, app, rec) |
| 553 | return bucket |
| 554 | } |
| 555 | |
| 556 | // storeVarsDelivered reports whether the app's config still holds the store |
| 557 | // coordinates seedStore delivered. |
| 558 | func storeVarsDelivered(t *testing.T, fx *consoleFixture, app string) bool { |
| 559 | t.Helper() |
| 560 | bucketName, _, _, _, _ := objstore.EnvNames(objstore.DefaultEnvPrefix) |
| 561 | _, ok := appVars(t, fx, app)[bucketName] |
| 562 | return ok |
| 563 | } |
| 564 | |
| 565 | // noStoreDestroyAudit asserts nothing claimed to have destroyed a store — the |
| 566 | // trail a refusal that ran too late would leave. |
| 567 | func noStoreDestroyAudit(t *testing.T, fx *consoleFixture, app string) { |
| 568 | t.Helper() |
| 569 | events, err := fx.st.Events(OpsAccountID, app, 20) |
| 570 | if err != nil { |
| 571 | t.Fatal(err) |
| 572 | } |
| 573 | for _, e := range events { |
| 574 | if e.Action == "store-destroy" { |
| 575 | t.Fatalf("a refused purge audited a store teardown: %+v", e) |
| 576 | } |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | // estateStanding asserts the backend still holds everything seedStore built — |
| 581 | // the yardstick for "a refusal destroyed nothing". |
| 582 | func estateStanding(t *testing.T, fx *consoleFixture, fake *objstore.MemProvisioner, app, bucket, when string) { |
| 583 | t.Helper() |
| 584 | if len(fake.Buckets[bucket]) == 0 { |
| 585 | t.Fatalf("%s: the store bucket %s is empty or gone — objects were destroyed", when, bucket) |
| 586 | } |
| 587 | if _, ok := fake.Users[objstore.UserName(fx.sqid, app)]; !ok { |
| 588 | t.Fatalf("%s: the store's IAM principal was deleted", when) |
| 589 | } |
| 590 | } |
| 591 | |
| 592 | // estateGone asserts the whole backend estate is down: no bucket, no principal. |
| 593 | func estateGone(t *testing.T, fake *objstore.MemProvisioner, when string) { |
| 594 | t.Helper() |
| 595 | if len(fake.Buckets) != 0 || len(fake.Users) != 0 { |
| 596 | t.Fatalf("%s left an estate standing: buckets %v, users %v", when, fake.Buckets, fake.Users) |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | // A console with no storage backend configured cannot tear a store down, so it |
| 601 | // must not purge the app either: the prefix sweep would delete store.json — |
| 602 | // the ONLY record naming a live bucket and a live key — and nothing would ever |
| 603 | // find them again. The refusal names the variable that fixes it. |
| 604 | func TestPurgeRefusesAStoreThisConsoleCannotTearDown(t *testing.T) { |
| 605 | fx, c, csrf := trashPageFixture(t) |
| 606 | fake := armStoreBackend(t, fx) |
| 607 | bucket := seedStore(t, fx, fake, "kass", false) |
| 608 | fx.srv.cfg.StoreProvisioner = nil // the deployment shape with no backend |
| 609 | |
| 610 | status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 611 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 612 | if status != http.StatusSeeOther { |
| 613 | t.Fatalf("purge with no backend = %d:\n%s", status, body) |
| 614 | } |
| 615 | estateStanding(t, fx, fake, "kass", bucket, "a backend-less purge") |
| 616 | if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { |
| 617 | t.Fatalf("the store record did not survive the refusal: %+v", rec) |
| 618 | } |
| 619 | if len(appKeys(t, fx, "kass")) == 0 { |
| 620 | t.Fatal("the app's deployment objects were swept by a refused purge") |
| 621 | } |
| 622 | if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 623 | t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err) |
| 624 | } |
| 625 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "CARLOS_STORE_BACKEND") { |
| 626 | t.Fatalf("the refusal does not name the backend variable:\n%s", page) |
| 627 | } |
| 628 | } |
| 629 | |
| 630 | // TestPurgeRefusesNoMailBackendBeforeTouchingTheStore: R5, review |
| 631 | // follow-up. The "this app has an email estate but no MailProvisioner" |
| 632 | // refusal used to sit AFTER objstore.Teardown had already destroyed the |
| 633 | // object store, mirroring the very bug |
| 634 | // TestPurgeRefusesAStoreThisConsoleCannotTearDown above already guards |
| 635 | // against on the store side — a purge that was always going to be refused |
| 636 | // destroyed the store first. Same shape, but for the email-side refusal: |
| 637 | // the store must still be standing after it. |
| 638 | func TestPurgeRefusesNoMailBackendBeforeTouchingTheStore(t *testing.T) { |
| 639 | fx, c, csrf := trashPageFixture(t) |
| 640 | fake := armStoreBackend(t, fx) |
| 641 | bucket := seedStore(t, fx, fake, "kass", false) |
| 642 | |
| 643 | // An email estate with no MailProvisioner configured — this deployment |
| 644 | // cannot tear it down, so the whole purge must refuse before it |
| 645 | // touches anything. |
| 646 | now := time.Now() |
| 647 | if _, err := mail.Declare(context.Background(), fx.rel, release.OpsAccount, "kass", |
| 648 | "KASS_SMTP", "noreply@kass.example.com", "paul@keymail.dev", now); err != nil { |
| 649 | t.Fatal(err) |
| 650 | } |
| 651 | mrec, metag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "kass") |
| 652 | if err != nil { |
| 653 | t.Fatal(err) |
| 654 | } |
| 655 | mrec.Provisioned = &mail.Provisioned{IAMUser: "u", KeyID: "AKIA1", Active: true, At: now} |
| 656 | if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, "kass", mrec, metag); err != nil { |
| 657 | t.Fatal(err) |
| 658 | } |
| 659 | fx.srv.cfg.MailProvisioner = nil |
| 660 | |
| 661 | status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 662 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 663 | if status != http.StatusSeeOther { |
| 664 | t.Fatalf("purge with no mail backend = %d:\n%s", status, body) |
| 665 | } |
| 666 | // R5: the store must be untouched — the refusal must have fired BEFORE |
| 667 | // objstore.Teardown, not after. |
| 668 | estateStanding(t, fx, fake, "kass", bucket, "a purge refused for lacking a mail backend") |
| 669 | if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { |
| 670 | t.Fatalf("the store record did not survive the refusal: %+v", rec) |
| 671 | } |
| 672 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "no SES backend configured") { |
| 673 | t.Fatalf("the refusal does not name the missing backend:\n%s", page) |
| 674 | } |
| 675 | } |
| 676 | |
| 677 | // The whole point of the door: the backend estate comes down FIRST, and only |
| 678 | // then is the deployment prefix swept — store.json included, now that it names |
| 679 | // nothing. |
| 680 | func TestPurgeTearsTheStoreDownBeforeSweepingThePrefix(t *testing.T) { |
| 681 | fx, c, csrf := trashPageFixture(t) |
| 682 | fake := armStoreBackend(t, fx) |
| 683 | seedStore(t, fx, fake, "kass", false) |
| 684 | before := serial(t, fx) |
| 685 | |
| 686 | status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 687 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 688 | if status != http.StatusSeeOther { |
| 689 | t.Fatalf("purge = %d:\n%s", status, body) |
| 690 | } |
| 691 | estateGone(t, fake, "a completed purge") |
| 692 | if keys := appKeys(t, fx, "kass"); len(keys) != 0 { |
| 693 | t.Fatalf("the deployment bucket still holds %v", keys) |
| 694 | } |
| 695 | if rec := storedRecord(t, fx, "kass"); rec != nil { |
| 696 | t.Fatalf("the store record survived the purge: %+v", rec) |
| 697 | } |
| 698 | // Both halves are audited: the store teardown is a separate, irreversible |
| 699 | // act on a separate estate, and an operator reading the trail must see it. |
| 700 | auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "carlos-bab-kass") |
| 701 | auditedBy(t, fx, "kass", "purge", "paul@keymail.dev", "kass") |
| 702 | if got := serial(t, fx); got != before { |
| 703 | t.Fatalf("serial after a purge = %d, want unchanged (%d)", got, before) |
| 704 | } |
| 705 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "free to claim again") { |
| 706 | t.Fatalf("no purge flash:\n%s", page) |
| 707 | } |
| 708 | } |
| 709 | |
| 710 | // A grant that crashed before recording itself leaves a record with `grant` and |
| 711 | // no `provisioned` — over a bucket, a user and a key that are all real. The |
| 712 | // guard is on EITHER block for exactly this: reading it as "nothing to tear |
| 713 | // down" would sweep the only witness to a live, billing estate. |
| 714 | func TestPurgeTearsDownACrashedGrant(t *testing.T) { |
| 715 | fx, c, csrf := trashPageFixture(t) |
| 716 | fake := armStoreBackend(t, fx) |
| 717 | seedStore(t, fx, fake, "kass", true) |
| 718 | if rec := storedRecord(t, fx, "kass"); rec.Provisioned != nil { |
| 719 | t.Fatal("the crashed-grant fixture recorded a provisioned block") |
| 720 | } |
| 721 | |
| 722 | status, _, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 723 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 724 | if status != http.StatusSeeOther { |
| 725 | t.Fatalf("purge of a crashed grant = %d:\n%s", status, body) |
| 726 | } |
| 727 | estateGone(t, fake, "a purge over a crashed grant") |
| 728 | if keys := appKeys(t, fx, "kass"); len(keys) != 0 { |
| 729 | t.Fatalf("the deployment bucket still holds %v", keys) |
| 730 | } |
| 731 | auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "carlos-bab-kass") |
| 732 | } |
| 733 | |
| 734 | // The production flag outranks deletion all the way down — and that promise is |
| 735 | // only kept if it is checked BEFORE the store comes down. A refusal that |
| 736 | // arrives after the teardown has already destroyed a LIVE app's bucket, its |
| 737 | // credentials and the variables its hosts are running on, and purge announces |
| 738 | // nothing, so those hosts would keep serving dead credentials until the hourly |
| 739 | // floor. |
| 740 | func TestPurgeRefusesAProductionAppBeforeTouchingItsStore(t *testing.T) { |
| 741 | fx := newConsoleFixture(t) |
| 742 | ctx := context.Background() |
| 743 | // Flagged BEFORE the trash, bucket-direct — the state only a flag set after |
| 744 | // the fact can reach, which is exactly the one this ordering protects. |
| 745 | if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { |
| 746 | t.Fatal(err) |
| 747 | } |
| 748 | c := newBrowser(t) |
| 749 | signUp(t, fx, c, "code-purge-prod-store") |
| 750 | trashKass(t, fx, "paul@keymail.dev", time.Now()) |
| 751 | csrf := sessionToken(t, fx, c) |
| 752 | fake := armStoreBackend(t, fx) |
| 753 | bucket := seedStore(t, fx, fake, "kass", false) |
| 754 | |
| 755 | status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 756 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 757 | if status != http.StatusSeeOther { |
| 758 | t.Fatalf("purge of a production app with a store = %d:\n%s", status, body) |
| 759 | } |
| 760 | estateStanding(t, fx, fake, "kass", bucket, "a purge refused for production") |
| 761 | if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { |
| 762 | t.Fatalf("the store record was destroyed by a refused purge: %+v", rec) |
| 763 | } |
| 764 | if !storeVarsDelivered(t, fx, "kass") { |
| 765 | t.Fatal("the delivered store variables were unset by a refused purge") |
| 766 | } |
| 767 | noStoreDestroyAudit(t, fx, "kass") |
| 768 | if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 769 | t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err) |
| 770 | } |
| 771 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "the off-switch on the app page") { |
| 772 | t.Fatalf("the refusal does not name the off-switch:\n%s", page) |
| 773 | } |
| 774 | } |
| 775 | |
| 776 | // "A restore that landed while this form sat open wins" — handlePurgeApp's own |
| 777 | // promise, which is worth nothing if the store is already gone by the time the |
| 778 | // marker is re-read. The app here is LIVE again: destroying its store would be |
| 779 | // destroying a running app's data. |
| 780 | func TestPurgeRefusesARestoredAppBeforeTouchingItsStore(t *testing.T) { |
| 781 | fx, c, csrf := trashPageFixture(t) |
| 782 | fake := armStoreBackend(t, fx) |
| 783 | bucket := seedStore(t, fx, fake, "kass", false) |
| 784 | // The restore lands while the "Delete now" form sits open. |
| 785 | if err := release.RestoreApp(context.Background(), fx.rel, fx.sqid, "kass"); err != nil { |
| 786 | t.Fatal(err) |
| 787 | } |
| 788 | |
| 789 | status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 790 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 791 | if status != http.StatusSeeOther { |
| 792 | t.Fatalf("purge of a restored app with a store = %d:\n%s", status, body) |
| 793 | } |
| 794 | estateStanding(t, fx, fake, "kass", bucket, "a purge refused for a restore") |
| 795 | if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { |
| 796 | t.Fatalf("the store record was destroyed by a refused purge: %+v", rec) |
| 797 | } |
| 798 | if !storeVarsDelivered(t, fx, "kass") { |
| 799 | t.Fatal("the delivered store variables were unset by a refused purge") |
| 800 | } |
| 801 | noStoreDestroyAudit(t, fx, "kass") |
| 802 | if len(appKeys(t, fx, "kass")) == 0 { |
| 803 | t.Fatal("the restored app's deployment objects were swept") |
| 804 | } |
| 805 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "not in the trash") { |
| 806 | t.Fatalf("the refusal does not say the app is out of the trash:\n%s", page) |
| 807 | } |
| 808 | } |
| 809 | |
| 810 | // purgeAdvice must be honest in BOTH directions: the guard wraps |
| 811 | // ErrStoreProvisioned for an unreadable store.json too, so this sentence |
| 812 | // cannot claim a store certainly exists — and it must still name the way out. |
| 813 | func TestPurgeAdviceMapsAStoreRefusalWithoutOverclaiming(t *testing.T) { |
| 814 | msg := purgeAdvice(fmt.Errorf("bab/kass: %w", release.ErrStoreProvisioned), "kass") |
| 815 | for _, want := range []string{"kass", "object store", "Settings"} { |
| 816 | if !strings.Contains(msg, want) { |
| 817 | t.Fatalf("purgeAdvice = %q, want it to mention %q", msg, want) |
| 818 | } |
| 819 | } |
| 820 | if !strings.Contains(msg, "may have") { |
| 821 | t.Fatalf("purgeAdvice = %q, want it to admit the store may only MAYBE exist "+ |
| 822 | "(the guard wraps this sentinel for an unreadable record too)", msg) |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | // ---- purge and the email estate (I2, review follow-up) -------------------- |
| 827 | // |
| 828 | // mail.Teardown had NO production caller anywhere before this fix. A purged |
| 829 | // app left a live IAM user holding an active key, its SES identity, its |
| 830 | // published DNS, and — worst — the create-only GLOBAL domain claim at |
| 831 | // email/domain-claims/<domain>.json. Because release.PurgeApp removes |
| 832 | // email.json while DomainRecord.Apps still names the deleted app, the |
| 833 | // last-referrer branch inside mail.Teardown could never fire again for that |
| 834 | // domain once the app was gone — the domain was then denied to every CARLOS |
| 835 | // account, including its rightful owner, permanently. |
| 836 | // |
| 837 | // The proof used here is the claim's own semantics, not an internal peek at |
| 838 | // bucket keys: a SECOND, unrelated account trying to ensure the SAME domain |
| 839 | // is refused with ErrDomainClaimed before the purge (the claim is doing its |
| 840 | // job), and succeeds after (the purge released it). |
| 841 | func TestPurgeReleasesTheDomainClaim(t *testing.T) { |
| 842 | fx, c, csrf := trashPageFixture(t) |
| 843 | fake := mail.NewMemProvisioner("1") |
| 844 | writer := dnsrec.NewMemWriter() |
| 845 | fx.srv.cfg.MailProvisioner = fake |
| 846 | fx.srv.cfg.DNSWriter = writer |
| 847 | dep := mail.Deployment{Region: "eu-west-1"} // no PlatformSuffix: a custom |
| 848 | // domain keeps this test about the claim gate alone, not the SEPARATE |
| 849 | // platform-name-ownership gate (walk.go's platformNameOwnedBy), which |
| 850 | // would otherwise refuse the second account for an unrelated reason. |
| 851 | |
| 852 | ctx := context.Background() |
| 853 | now := time.Now() |
| 854 | domain := "shared-claim.example.com" |
| 855 | // Deliberately no mail.Declare/Enable — kass has a domain record (and |
| 856 | // the claim it holds) but no Provisioned credential, exactly the shape |
| 857 | // `carlos email domains add` alone leaves. mail.Teardown must still |
| 858 | // find and release it: the domain-referrer set, not just a delivered |
| 859 | // credential, is what the claim traces back to. |
| 860 | if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, fx.sqid, "kass", domain, now); err != nil { |
| 861 | t.Fatalf("EnsureDomain for kass: %v", err) |
| 862 | } |
| 863 | |
| 864 | if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) { |
| 865 | t.Fatalf("a second account's EnsureDomain before the purge = %v, want ErrDomainClaimed", err) |
| 866 | } |
| 867 | |
| 868 | status, _, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| 869 | url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 870 | if status != http.StatusSeeOther { |
| 871 | t.Fatalf("purge = %d:\n%s", status, body) |
| 872 | } |
| 873 | auditedBy(t, fx, "kass", "email-teardown", "paul@keymail.dev", "email estate") |
| 874 | |
| 875 | if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, "zzzz", "other", domain, now); err != nil { |
| 876 | t.Fatalf("EnsureDomain after the purge = %v, want the claim released for anyone to take", err) |
| 877 | } |
| 878 | } |
| 879 | |
| 880 | // Spec §9 "Deadline display": a temporary and a permanent app trashed |
| 881 | // together show 3 and 30 days, on the Trash page and in the trash API. |
| 882 | func TestTrashShowsEachAppsOwnDeadline(t *testing.T) { |
| 883 | fx := newConsoleFixture(t) |
| 884 | tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour)) |
| 885 | token := cliToken(t, fx, "paul@keymail.dev", "code-deadlines") |
| 886 | until := map[string]time.Time{} |
| 887 | for _, app := range []string{"tmp-a", "kass"} { |
| 888 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token) |
| 889 | if status != http.StatusOK { |
| 890 | t.Fatalf("delete %s = %d %v", app, status, out) |
| 891 | } |
| 892 | u, err := time.Parse(time.RFC3339, str(t, out, "restorable_until")) |
| 893 | if err != nil { |
| 894 | t.Fatal(err) |
| 895 | } |
| 896 | until[app] = u |
| 897 | } |
| 898 | tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass") |
| 899 | if want := time.Unix(tmp.DeletedAt, 0).Add(72 * time.Hour); !until["tmp-a"].Equal(want) { |
| 900 | t.Errorf("tmp-a restorable_until = %v, want %v", until["tmp-a"], want) |
| 901 | } |
| 902 | if want := time.Unix(kass.DeletedAt, 0).Add(30 * 24 * time.Hour); !until["kass"].Equal(want) { |
| 903 | t.Errorf("kass restorable_until = %v, want %v", until["kass"], want) |
| 904 | } |
| 905 | c, _ := signInAs(t, fx, "paul@keymail.dev", "code-deadlines-web") |
| 906 | _, _, page := get(t, c, fx.u("/trash")) |
| 907 | for app, u := range until { |
| 908 | if !strings.Contains(page, u.UTC().Format("2006-01-02 15:04 UTC")) { |
| 909 | t.Errorf("the Trash page does not show %s's deadline %v", app, u) |
| 910 | } |
| 911 | } |
| 912 | } |
| 913 | |
| 914 | // Astra plan round 3: the delete confirmation on Settings promises the window |
| 915 | // the app will actually get, before anything is deleted: 3 days for a |
| 916 | // temporary app, 30 for a permanent one. The number sits in its own element |
| 917 | // so the assertion survives any rewording of the sentence around it. |
| 918 | func TestDeleteConfirmationNamesEachAppsWindow(t *testing.T) { |
| 919 | fx := newConsoleFixture(t) |
| 920 | tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour)) |
| 921 | c, _ := signInAs(t, fx, "paul@keymail.dev", "code-delete-window") |
| 922 | for app, days := range map[string]string{"tmp-a": "3", "kass": "30"} { |
| 923 | status, _, page := get(t, c, fx.u("/apps/"+app+"/settings/delete")) |
| 924 | if status != http.StatusOK { |
| 925 | t.Fatalf("%s delete settings = %d", app, status) |
| 926 | } |
| 927 | if want := `class="restore-days">` + days + ` days<`; !strings.Contains(page, want) { |
| 928 | t.Errorf("%s: the delete confirmation does not promise %s days (want %q in the page)", app, days, want) |
| 929 | } |
| 930 | } |
| 931 | } |
| 932 | |