| 1 | package console |
| 2 | |
| 3 | // The app Settings tab's write half (appsettings.go): rename, delete, and the |
| 4 | // account-move repair, plus the store verb rename is built on. |
| 5 | // |
| 6 | // The theme running through all of it is that this console may only change its |
| 7 | // OWN record. Every test here therefore checks two things about a refusal: that |
| 8 | // the person is told, and that nothing outside this database moved — because |
| 9 | // the whole reason renaming is refused after a ship, and deletion is refused on |
| 10 | // a production app, is that the other stores would not follow. |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "errors" |
| 15 | "github.com/carlosframework/platform/internal/certwarm" |
| 16 | "github.com/carlosframework/platform/internal/domains" |
| 17 | "github.com/carlosframework/platform/internal/status" |
| 18 | "net/http" |
| 19 | "net/url" |
| 20 | "strings" |
| 21 | "testing" |
| 22 | "time" |
| 23 | |
| 24 | "github.com/carlosframework/platform/internal/dnsrec" |
| 25 | "github.com/carlosframework/platform/internal/mail" |
| 26 | "github.com/carlosframework/platform/internal/objstore" |
| 27 | "github.com/carlosframework/platform/internal/registry" |
| 28 | "github.com/carlosframework/platform/internal/release" |
| 29 | ) |
| 30 | |
| 31 | // ---- Store.RenameAppIn ------------------------------------------------------ |
| 32 | |
| 33 | // renameStore is an account with one app in it — the smallest world a rename |
| 34 | // means anything in. |
| 35 | func renameStore(t *testing.T, app string) (*Store, int64) { |
| 36 | t.Helper() |
| 37 | s := openTestStore(t) |
| 38 | id := testAccountID() |
| 39 | if err := s.CreateAccount(id, "rn"+app, "Renameco", "paul@keymail.dev", time.Now()); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | if created, err := s.CreateAppIn(id, app, "paul@keymail.dev", time.Now()); err != nil || !created { |
| 43 | t.Fatalf("CreateAppIn = %v %v", created, err) |
| 44 | } |
| 45 | return s, id |
| 46 | } |
| 47 | |
| 48 | // The happy path, and the half that is easy to forget: the app's audit history |
| 49 | // follows it. Its story belongs to the app, not to a name that no longer |
| 50 | // resolves — a rename that stranded the trail would leave the Activity tab of |
| 51 | // the renamed app blank and the events unreachable under any name. |
| 52 | // |
| 53 | // RED as written (2026-08-07): RenameAppIn's UPDATE is guarded by |
| 54 | // `deleted_at IS NULL`, and apps.deleted_at is `INTEGER NOT NULL DEFAULT 0` — |
| 55 | // so the guard matches no row, ever, and every rename returns "this account |
| 56 | // does not claim %q". The guard the rest of the file uses for "live" is |
| 57 | // `deleted_at = 0`. |
| 58 | func TestRenameAppInStoreRenamesRowAndAudit(t *testing.T) { |
| 59 | s, id := renameStore(t, "alpha") |
| 60 | if err := s.RecordEvent(id, "alpha", "paul@keymail.dev", "create", "created app alpha", time.Now()); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | |
| 64 | if err := s.RenameAppIn(id, "alpha", "beta"); err != nil { |
| 65 | t.Fatalf("RenameAppIn(alpha -> beta) = %v, want the rename to land", err) |
| 66 | } |
| 67 | if has, err := s.HasAppIn(id, "beta"); err != nil || !has { |
| 68 | t.Fatalf("HasAppIn(beta) = %v %v, want the row under its new name", has, err) |
| 69 | } |
| 70 | if has, err := s.HasAppIn(id, "alpha"); err != nil || has { |
| 71 | t.Fatalf("HasAppIn(alpha) = %v %v, want the old name gone", has, err) |
| 72 | } |
| 73 | moved, err := s.Events(id, "beta", 10) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if len(moved) != 1 || moved[0].Detail != "created app alpha" { |
| 78 | t.Fatalf("Events(beta) = %+v, want the app's history to have followed it", moved) |
| 79 | } |
| 80 | left, err := s.Events(id, "alpha", 10) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if len(left) != 0 { |
| 85 | t.Fatalf("Events(alpha) = %+v, want nothing left under the old name", left) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // The name must be free in this account — and a TRASHED row still owns its |
| 90 | // name until it is purged, exactly as CreateAppIn treats it. Renaming onto one |
| 91 | // would resurrect a collision the moment the trashed app was restored. |
| 92 | func TestRenameAppInRefusesTakenNames(t *testing.T) { |
| 93 | s, id := renameStore(t, "alpha") |
| 94 | if _, err := s.CreateAppIn(id, "live", "paul@keymail.dev", time.Now()); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if _, err := s.CreateAppIn(id, "binned", "paul@keymail.dev", time.Now()); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if err := s.TrashAppRow(id, "binned", time.Now()); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | for _, to := range []string{"live", "binned"} { |
| 104 | err := s.RenameAppIn(id, "alpha", to) |
| 105 | if err == nil { |
| 106 | t.Fatalf("RenameAppIn(alpha -> %s) succeeded onto a claimed name", to) |
| 107 | } |
| 108 | if !strings.Contains(err.Error(), "already has an app") { |
| 109 | t.Fatalf("RenameAppIn(alpha -> %s) = %v, want the taken-name refusal", to, err) |
| 110 | } |
| 111 | // And the refusal is total: nothing moved. |
| 112 | if has, err := s.HasAppIn(id, "alpha"); err != nil || !has { |
| 113 | t.Fatalf("HasAppIn(alpha) = %v %v after a refused rename, want it untouched", has, err) |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // Legacy owners-table grants are keyed by BARE app name across all accounts, so |
| 119 | // rewriting one could hand an account's grant to another, and renaming ONTO one |
| 120 | // would let the new name inherit a grant nobody issued it. Both directions are |
| 121 | // refused. |
| 122 | func TestRenameAppInRefusesLegacyGrants(t *testing.T) { |
| 123 | s, id := renameStore(t, "alpha") |
| 124 | if err := s.Grant("paul@keymail.dev", "alpha"); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil || |
| 128 | !strings.Contains(err.Error(), "legacy owner grant") { |
| 129 | t.Fatalf("renaming FROM a granted name = %v, want the legacy-grant refusal", err) |
| 130 | } |
| 131 | if err := s.Revoke("paul@keymail.dev", "alpha"); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if err := s.Grant("paul@keymail.dev", "gamma"); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil || |
| 138 | !strings.Contains(err.Error(), "legacy owner grant") { |
| 139 | t.Fatalf("renaming ONTO a granted name = %v, want the legacy-grant refusal", err) |
| 140 | } |
| 141 | if has, err := s.HasAppIn(id, "alpha"); err != nil || !has { |
| 142 | t.Fatalf("HasAppIn(alpha) = %v %v after the refusals, want it untouched", has, err) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // Renaming something this account does not claim is an error, not a silent |
| 147 | // no-op — the handler turns it into "could not rename", and a quiet success |
| 148 | // would tell the owner an app moved when nothing did. |
| 149 | func TestRenameAppInRefusesMissingSource(t *testing.T) { |
| 150 | s, id := renameStore(t, "alpha") |
| 151 | err := s.RenameAppIn(id, "ghost", "gamma") |
| 152 | if err == nil { |
| 153 | t.Fatal("renaming an app this account does not claim reported success") |
| 154 | } |
| 155 | if !strings.Contains(err.Error(), "does not claim") { |
| 156 | t.Fatalf("RenameAppIn(ghost -> gamma) = %v, want the unclaimed-source refusal", err) |
| 157 | } |
| 158 | if has, err := s.HasAppIn(id, "gamma"); err != nil || has { |
| 159 | t.Fatalf("HasAppIn(gamma) = %v %v, want no row conjured by the failed rename", has, err) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | // ---- handleAppRename -------------------------------------------------------- |
| 164 | |
| 165 | // freshApp claims an app name in the fixture account with nothing behind it: |
| 166 | // no bucket keys, no routes. That is the ONLY state a rename is offered in, so |
| 167 | // it is the state most of these tests start from. |
| 168 | func freshApp(t *testing.T, fx *consoleFixture, app string) { |
| 169 | t.Helper() |
| 170 | if created, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil || !created { |
| 171 | t.Fatalf("CreateAppIn(%s) = %v %v", app, created, err) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // shipSomething puts one object under the app's bucket prefix. Which object is |
| 176 | // beside the point: appDeployEmpty refuses on ANY key, because one key is |
| 177 | // enough to prove the name is baked into a storage layer this handler cannot |
| 178 | // rewrite. |
| 179 | func shipSomething(t *testing.T, fx *consoleFixture, app string) { |
| 180 | t.Helper() |
| 181 | key := release.AppPrefix(fx.sqid, app) + "releases/v1/manifest.json" |
| 182 | if _, err := fx.rel.Put(context.Background(), key, []byte(`{"schema":1}`), ""); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // The card, then the rename: a fresh app's name is still only a record here, |
| 188 | // so the whole ceremony is confirm-and-go, with no passkey. |
| 189 | // |
| 190 | // RED as written (2026-08-07) for the same reason as |
| 191 | // TestRenameAppInStoreRenamesRowAndAudit: the store verb underneath cannot |
| 192 | // match a live row, so the confirmed POST flashes "Could not rename" and |
| 193 | // lands back on the OLD settings URL. The card half of this test is green. |
| 194 | func TestRenameHandlerConfirmsThenRenames(t *testing.T) { |
| 195 | fx := newConsoleFixture(t) |
| 196 | freshApp(t, fx, "sprout") |
| 197 | c := signInPaul(t, fx) |
| 198 | csrf := sessionToken(t, fx, c) |
| 199 | form := url.Values{"csrf": {csrf}, "to": {"sapling"}} |
| 200 | |
| 201 | status, loc, body := postForm(t, c, fx.u("/apps/sprout/rename"), form) |
| 202 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 203 | t.Fatalf("renaming a never-shipped app demanded a passkey (-> %q)", loc) |
| 204 | } |
| 205 | card := confirmCard(t, status, body, "Rename sprout to sapling?", csrf) |
| 206 | if !strings.Contains(card, `<input type="hidden" name="to" value="sapling">`) { |
| 207 | t.Fatalf("the card does not carry the new name:\n%s", card) |
| 208 | } |
| 209 | if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has { |
| 210 | t.Fatalf("HasAppIn(sprout) = %v %v after the card, want it unrenamed", has, err) |
| 211 | } |
| 212 | |
| 213 | form.Set("confirmed", "1") |
| 214 | status, loc, body = postForm(t, c, fx.u("/apps/sprout/rename"), form) |
| 215 | if status != http.StatusSeeOther || loc != fx.path("/apps/sapling/settings") { |
| 216 | t.Fatalf("confirmed rename = %d -> %q, want 303 to the NEW settings URL:\n%s", status, loc, body) |
| 217 | } |
| 218 | if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || !has { |
| 219 | t.Fatalf("HasAppIn(sapling) = %v %v, want the app under its new name", has, err) |
| 220 | } |
| 221 | if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || has { |
| 222 | t.Fatalf("HasAppIn(sprout) = %v %v, want the old name gone", has, err) |
| 223 | } |
| 224 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "Renamed sprout to sapling.") { |
| 225 | t.Fatalf("no rename flash:\n%s", page) |
| 226 | } |
| 227 | auditedBy(t, fx, "sapling", "rename", "paul@keymail.dev", "renamed sprout to sapling") |
| 228 | } |
| 229 | |
| 230 | // One key under the app's prefix is enough. The refusal is a flash, not a card: |
| 231 | // the handler decided before asking, so there is nothing to confirm. |
| 232 | func TestRenameHandlerRefusesShippedApp(t *testing.T) { |
| 233 | fx := newConsoleFixture(t) |
| 234 | freshApp(t, fx, "shipped") |
| 235 | shipSomething(t, fx, "shipped") |
| 236 | c := signInPaul(t, fx) |
| 237 | csrf := sessionToken(t, fx, c) |
| 238 | |
| 239 | status, loc, body := postForm(t, c, fx.u("/apps/shipped/rename"), |
| 240 | url.Values{"csrf": {csrf}, "to": {"renamed"}, "confirmed": {"1"}}) |
| 241 | if status != http.StatusSeeOther || loc != fx.path("/apps/shipped/settings") { |
| 242 | t.Fatalf("rename of a shipped app = %d -> %q, want the flash redirect:\n%s", status, loc, body) |
| 243 | } |
| 244 | notACard(t, body) |
| 245 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "This app has shipped") { |
| 246 | t.Fatalf("no shipped refusal flash:\n%s", page) |
| 247 | } |
| 248 | if has, err := fx.st.HasAppIn(OpsAccountID, "shipped"); err != nil || !has { |
| 249 | t.Fatalf("HasAppIn(shipped) = %v %v, want the app untouched", has, err) |
| 250 | } |
| 251 | if has, err := fx.st.HasAppIn(OpsAccountID, "renamed"); err != nil || has { |
| 252 | t.Fatalf("HasAppIn(renamed) = %v %v, want no new row", has, err) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // A route wearing the name is the other wider store: the name is the address. |
| 257 | // Checked at ACTION time, not from the page's advice — a route added since the |
| 258 | // page rendered must still stop the rename. |
| 259 | func TestRenameHandlerRefusesRoutedApp(t *testing.T) { |
| 260 | fx := newConsoleFixture(t) |
| 261 | freshApp(t, fx, "wired") |
| 262 | c := signInPaul(t, fx) |
| 263 | csrf := sessionToken(t, fx, c) |
| 264 | // Added AFTER the app was claimed and with nothing in the bucket, so the |
| 265 | // deploy-empty check passes and the ROUTE check is what refuses. |
| 266 | fx.addRoute(t, registry.Route{Host: "wired.carlosapp.dev", Kind: "instance", Status: "ready", |
| 267 | App: "wired", Channel: "stable", Addr: "127.0.0.1:9031"}) |
| 268 | |
| 269 | status, loc, body := postForm(t, c, fx.u("/apps/wired/rename"), |
| 270 | url.Values{"csrf": {csrf}, "to": {"unwired"}, "confirmed": {"1"}}) |
| 271 | if status != http.StatusSeeOther || loc != fx.path("/apps/wired/settings") { |
| 272 | t.Fatalf("rename of a routed app = %d -> %q:\n%s", status, loc, body) |
| 273 | } |
| 274 | notACard(t, body) |
| 275 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "A host still routes this name") { |
| 276 | t.Fatalf("no routed refusal flash:\n%s", page) |
| 277 | } |
| 278 | if has, err := fx.st.HasAppIn(OpsAccountID, "unwired"); err != nil || has { |
| 279 | t.Fatalf("HasAppIn(unwired) = %v %v, want no rename", has, err) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // Renaming is the owner's. A member meets the byte-identical 404 — before any |
| 284 | // card could concede the action exists. |
| 285 | func TestRenameHandlerIsOwnerOnly(t *testing.T) { |
| 286 | fx := newConsoleFixture(t) |
| 287 | freshApp(t, fx, "sprout") |
| 288 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | fx.setUser("sam@keymail.dev") |
| 292 | c := newBrowser(t) |
| 293 | signUp(t, fx, c, "code-sam-rename") |
| 294 | csrf := sessionToken(t, fx, c) |
| 295 | |
| 296 | status, _, memberBody := postForm(t, c, fx.u("/apps/sprout/rename"), |
| 297 | url.Values{"csrf": {csrf}, "to": {"sapling"}}) |
| 298 | if status != http.StatusNotFound { |
| 299 | t.Fatalf("member renaming = %d, want 404", status) |
| 300 | } |
| 301 | notACard(t, memberBody) |
| 302 | strangerBody := strangerRefusal(t, fx, "code-zoe-rename", fx.u("/apps/sprout/rename"), |
| 303 | url.Values{"to": {"sapling"}}) |
| 304 | if memberBody != strangerBody { |
| 305 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 306 | } |
| 307 | if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has { |
| 308 | t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err) |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | // CSRF is checked before ownership and before anything is read — a cross-site |
| 313 | // POST is refused with 403 and never reaches the store. |
| 314 | func TestRenameHandlerNeedsCSRF(t *testing.T) { |
| 315 | fx := newConsoleFixture(t) |
| 316 | freshApp(t, fx, "sprout") |
| 317 | c := signInPaul(t, fx) |
| 318 | |
| 319 | status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"), |
| 320 | url.Values{"csrf": {"not-the-token"}, "to": {"sapling"}, "confirmed": {"1"}}) |
| 321 | if status != http.StatusForbidden { |
| 322 | t.Fatalf("rename with a bad csrf = %d, want 403:\n%s", status, body) |
| 323 | } |
| 324 | if !strings.Contains(body, "Cross-site request blocked.") { |
| 325 | t.Fatalf("403 body does not name the reason:\n%s", body) |
| 326 | } |
| 327 | if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has { |
| 328 | t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | // ---- handleAppDelete -------------------------------------------------------- |
| 333 | |
| 334 | // appTombstone reads the bucket's deletion marker — the thing that actually |
| 335 | // makes a host stop serving. The console row alone is not a deletion. |
| 336 | func appTombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone { |
| 337 | t.Helper() |
| 338 | tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, app) |
| 339 | if err != nil { |
| 340 | t.Fatal(err) |
| 341 | } |
| 342 | return tomb |
| 343 | } |
| 344 | |
| 345 | // Typing the name is a "did you mean this app?" control, checked BEFORE the |
| 346 | // passkey so a slip does not cost a passkey touch — and, more to the point, |
| 347 | // does not delete anything. |
| 348 | func TestAppDeleteRequiresTheExactName(t *testing.T) { |
| 349 | fx := newConsoleFixture(t) |
| 350 | c := signInPaul(t, fx) |
| 351 | csrf := sessionToken(t, fx, c) |
| 352 | |
| 353 | status, loc, body := postForm(t, c, fx.u("/apps/kass/delete"), |
| 354 | url.Values{"csrf": {csrf}, "confirm": {"kas"}}) |
| 355 | if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 356 | t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body) |
| 357 | } |
| 358 | if loc != fx.path("/apps/kass/settings/delete") { |
| 359 | t.Fatalf("mistyped delete redirected to %q, want the Settings tab", loc) |
| 360 | } |
| 361 | // The apostrophe in "app's" is HTML-escaped on the way out, so the |
| 362 | // assertion takes the unambiguous tail of the sentence. |
| 363 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "name exactly to delete it.") { |
| 364 | t.Fatalf("no mistyped-name flash:\n%s", page) |
| 365 | } |
| 366 | if tomb := appTombstone(t, fx, "kass"); tomb != nil { |
| 367 | t.Fatalf("a mistyped delete wrote a trash marker: %+v", tomb) |
| 368 | } |
| 369 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 370 | t.Fatalf("HasAppIn = %v %v, want the row live", has, err) |
| 371 | } |
| 372 | var stashed int |
| 373 | fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) |
| 374 | if stashed != 0 { |
| 375 | t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway") |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | // The whole ceremony: the name, then the passkey, then the marker and the row, |
| 380 | // in that order, landing on the account home. |
| 381 | func TestAppDeleteTrashesBehindThePasskey(t *testing.T) { |
| 382 | fx := newConsoleFixture(t) |
| 383 | c := signInPaul(t, fx) |
| 384 | csrf := sessionToken(t, fx, c) |
| 385 | form := url.Values{"csrf": {csrf}, "confirm": {"kass"}} |
| 386 | |
| 387 | // Without the passkey, nothing moves. (postForm, not postGated: the |
| 388 | // un-stepped-up post is what proves the gate is there at all.) |
| 389 | status, loc, _ := postForm(t, c, fx.u("/apps/kass/delete"), form) |
| 390 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 391 | t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc) |
| 392 | } |
| 393 | if tomb := appTombstone(t, fx, "kass"); tomb != nil { |
| 394 | t.Fatalf("a trash marker was written before the passkey confirmation: %+v", tomb) |
| 395 | } |
| 396 | |
| 397 | status, loc, body := completeStepUp(t, fx, c, loc) |
| 398 | if status != http.StatusSeeOther || loc != fx.path("/") { |
| 399 | t.Fatalf("resumed delete = %d -> %q, want 303 to the account home:\n%s", status, loc, body) |
| 400 | } |
| 401 | tomb := appTombstone(t, fx, "kass") |
| 402 | if tomb == nil { |
| 403 | t.Fatal("no trash marker in the bucket — hosts would go on serving the app") |
| 404 | } |
| 405 | if tomb.Actor != "paul@keymail.dev" { |
| 406 | t.Fatalf("marker actor = %q, want the owner who deleted it", tomb.Actor) |
| 407 | } |
| 408 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has { |
| 409 | t.Fatalf("HasAppIn = %v %v, want the live row gone", has, err) |
| 410 | } |
| 411 | if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 412 | t.Fatalf("HasTrashedAppIn = %v %v, want the row in the trash", has, err) |
| 413 | } |
| 414 | auditedBy(t, fx, "kass", "trash", "paul@keymail.dev", "moved kass to the trash") |
| 415 | } |
| 416 | |
| 417 | // Production outranks deletion, all the way through the ceremony: the flag is |
| 418 | // read after the passkey, and refuses even a fully confirmed owner. |
| 419 | func TestAppDeleteRefusesAProductionApp(t *testing.T) { |
| 420 | fx := newConsoleFixture(t) |
| 421 | if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| 422 | release.OpsAccount, "kass", true, "test"); err != nil { |
| 423 | t.Fatal(err) |
| 424 | } |
| 425 | c := signInPaul(t, fx) |
| 426 | csrf := sessionToken(t, fx, c) |
| 427 | |
| 428 | status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/delete"), |
| 429 | url.Values{"csrf": {csrf}, "confirm": {"kass"}}) |
| 430 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/delete") { |
| 431 | t.Fatalf("delete of a production app = %d -> %q:\n%s", status, loc, body) |
| 432 | } |
| 433 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is marked as production, so deleting is refused") { |
| 434 | t.Fatalf("no production refusal flash:\n%s", page) |
| 435 | } |
| 436 | if tomb := appTombstone(t, fx, "kass"); tomb != nil { |
| 437 | t.Fatalf("a production app got a trash marker anyway: %+v", tomb) |
| 438 | } |
| 439 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 440 | t.Fatalf("HasAppIn = %v %v, want the row live", has, err) |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | // Deleting is the owner's, and the refusal is the byte-identical 404 — a |
| 445 | // member must not learn that the door exists. |
| 446 | func TestAppDeleteIsOwnerOnly(t *testing.T) { |
| 447 | fx := newConsoleFixture(t) |
| 448 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 449 | t.Fatal(err) |
| 450 | } |
| 451 | fx.setUser("sam@keymail.dev") |
| 452 | c := newBrowser(t) |
| 453 | signUp(t, fx, c, "code-sam-appdelete") |
| 454 | csrf := sessionToken(t, fx, c) |
| 455 | |
| 456 | status, _, memberBody := postForm(t, c, fx.u("/apps/kass/delete"), |
| 457 | url.Values{"csrf": {csrf}, "confirm": {"kass"}}) |
| 458 | if status != http.StatusNotFound { |
| 459 | t.Fatalf("member deleting an app = %d, want 404", status) |
| 460 | } |
| 461 | strangerBody := strangerRefusal(t, fx, "code-zoe-appdelete", fx.u("/apps/kass/delete"), |
| 462 | url.Values{"confirm": {"kass"}}) |
| 463 | if memberBody != strangerBody { |
| 464 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 465 | } |
| 466 | if tomb := appTombstone(t, fx, "kass"); tomb != nil { |
| 467 | t.Fatalf("a member's refused delete wrote a marker: %+v", tomb) |
| 468 | } |
| 469 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 470 | t.Fatalf("HasAppIn = %v %v, want the row live", has, err) |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | // ---- handleAppMoveAccount --------------------------------------------------- |
| 475 | |
| 476 | // stampKassRoutes re-writes the fixture's kass routes with a stamped account, |
| 477 | // which is what `carlos accounts migrate` leaves behind: the routes name the |
| 478 | // new account, the console record still names the old one. That disagreement |
| 479 | // IS the state this handler repairs — and the target comes from the routes, |
| 480 | // never the form. |
| 481 | func stampKassRoutes(t *testing.T, fx *consoleFixture, acct string) { |
| 482 | t.Helper() |
| 483 | fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 484 | Version: "v1", App: "kass", Channel: "stable", Account: acct, Addr: "127.0.0.1:9001"}) |
| 485 | fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready", |
| 486 | Version: "v1", App: "kass", Account: acct, Hibernate: true}) |
| 487 | } |
| 488 | |
| 489 | // The repair: card, passkey, and the record lands in the account the routes |
| 490 | // already deploy from — with the arrival recorded on BOTH accounts, since |
| 491 | // neither activity trail alone tells the whole story afterwards. |
| 492 | func TestAppMoveConfirmsThenMovesTheRecord(t *testing.T) { |
| 493 | fx := newConsoleFixture(t) |
| 494 | const destID = 4242 |
| 495 | if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | stampKassRoutes(t, fx, "cdef") |
| 499 | c := signInPaul(t, fx) |
| 500 | csrf := sessionToken(t, fx, c) |
| 501 | form := url.Values{"csrf": {csrf}} |
| 502 | |
| 503 | status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), form) |
| 504 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 505 | t.Fatalf("the owner met the passkey before the card (-> %q)", loc) |
| 506 | } |
| 507 | card := confirmCard(t, status, body, "Move kass to Second?", csrf) |
| 508 | if !strings.Contains(card, "Move to Second") { |
| 509 | t.Fatalf("the card's button does not name the destination:\n%s", card) |
| 510 | } |
| 511 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 512 | t.Fatalf("HasAppIn(ops) = %v %v after the card, want the record unmoved", has, err) |
| 513 | } |
| 514 | |
| 515 | form.Set("confirmed", "1") |
| 516 | status, loc, _ = postForm(t, c, fx.u("/apps/kass/move"), form) |
| 517 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 518 | t.Fatalf("confirmed move = %d -> %q, want a redirect into Keymail", status, loc) |
| 519 | } |
| 520 | if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has { |
| 521 | t.Fatalf("HasAppIn(dest) = %v %v before the passkey, want nothing moved", has, err) |
| 522 | } |
| 523 | |
| 524 | status, loc, body = completeStepUp(t, fx, c, loc) |
| 525 | if status != http.StatusSeeOther || loc != "/a/cdef/apps/kass" { |
| 526 | t.Fatalf("resumed move = %d -> %q, want 303 to the app under its new account:\n%s", status, loc, body) |
| 527 | } |
| 528 | if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || !has { |
| 529 | t.Fatalf("HasAppIn(dest) = %v %v, want the record in the destination account", has, err) |
| 530 | } |
| 531 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has { |
| 532 | t.Fatalf("HasAppIn(ops) = %v %v, want the record gone from the source", has, err) |
| 533 | } |
| 534 | // Both trails: the source records the departure, the destination the arrival. |
| 535 | auditedBy(t, fx, "kass", "move", "paul@keymail.dev", "moved kass to account Second (cdef)") |
| 536 | arrived, err := fx.st.Events(destID, "kass", 10) |
| 537 | if err != nil { |
| 538 | t.Fatal(err) |
| 539 | } |
| 540 | var found bool |
| 541 | for _, e := range arrived { |
| 542 | if e.Action == "move" && strings.Contains(e.Detail, "arrived from account") { |
| 543 | found = true |
| 544 | } |
| 545 | } |
| 546 | if !found { |
| 547 | t.Fatalf("destination events = %+v, want the arrival recorded there too", arrived) |
| 548 | } |
| 549 | } |
| 550 | |
| 551 | // Moving the record into an account the viewer does not belong to would strand |
| 552 | // the app for them. The refusal must not disclose that account as a move target. |
| 553 | func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) { |
| 554 | fx := newConsoleFixture(t) |
| 555 | const destID = 4343 |
| 556 | if err := fx.st.CreateAccount(destID, "cghi", "Third", "kim@keymail.dev", time.Now()); err != nil { |
| 557 | t.Fatal(err) |
| 558 | } |
| 559 | stampKassRoutes(t, fx, "cghi") |
| 560 | c := signInPaul(t, fx) |
| 561 | csrf := sessionToken(t, fx, c) |
| 562 | |
| 563 | status, _, body := postGated(t, fx, c, fx.u("/apps/kass/move"), |
| 564 | url.Values{"csrf": {csrf}, "confirmed": {"1"}}) |
| 565 | ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page") |
| 566 | if status != http.StatusNotFound || status != ghostStatus || body != ghostBody { |
| 567 | t.Fatalf("move into a stranger account = %d %q; unrouted = %d %q", status, body, ghostStatus, ghostBody) |
| 568 | } |
| 569 | notACard(t, body) |
| 570 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 571 | t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err) |
| 572 | } |
| 573 | if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has { |
| 574 | t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved", has, err) |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | // With no disagreement there is nothing to repair — and no card, because the |
| 579 | // handler has already decided. |
| 580 | func TestAppMoveRefusesWhenRoutesAgree(t *testing.T) { |
| 581 | fx := newConsoleFixture(t) |
| 582 | // The fixture's routes carry no stamped account, which reads as the ops |
| 583 | // account — the very account this page belongs to. |
| 584 | c := signInPaul(t, fx) |
| 585 | csrf := sessionToken(t, fx, c) |
| 586 | |
| 587 | status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}}) |
| 588 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| 589 | t.Fatalf("move with agreeing routes = %d -> %q:\n%s", status, loc, body) |
| 590 | } |
| 591 | notACard(t, body) |
| 592 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, |
| 593 | "there is nothing to move") { |
| 594 | t.Fatalf("no nothing-to-move flash:\n%s", page) |
| 595 | } |
| 596 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 597 | t.Fatalf("HasAppIn = %v %v, want the record untouched", has, err) |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | // A PARTIAL mismatch is not a move. The Settings card is only offered when |
| 602 | // every non-platform route disagrees (AllMismatched), but a direct POST — or a |
| 603 | // tab left open across a re-stamp — reaches the handler regardless, and the |
| 604 | // target it derives is only the FIRST route's account. Here that first route |
| 605 | // (docs.* sorts before kass.*, and List orders by host) is the ONLY one |
| 606 | // stamped elsewhere: acting on it would move the whole record to where a |
| 607 | // minority of the app's hosts deploy from, converting a partial disagreement |
| 608 | // into a total one the other way. The routes are the repair, so the handler |
| 609 | // says so — before the card, and before anything is written. |
| 610 | func TestAppMoveRefusesAPartialMismatch(t *testing.T) { |
| 611 | fx := newConsoleFixture(t) |
| 612 | const destID = 4444 |
| 613 | // A destination that would otherwise pass every later check — known to the |
| 614 | // console, and one paul belongs to — so the refusal can only be the |
| 615 | // disagreement itself. |
| 616 | if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil { |
| 617 | t.Fatal(err) |
| 618 | } |
| 619 | if err := fx.st.AddMember(destID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil { |
| 620 | t.Fatal(err) |
| 621 | } |
| 622 | // One of kass's two routes re-stamped, and it is the one that sorts first. |
| 623 | fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready", |
| 624 | Version: "v1", App: "kass", Account: "cdef", Hibernate: true}) |
| 625 | c := signInPaul(t, fx) |
| 626 | csrf := sessionToken(t, fx, c) |
| 627 | |
| 628 | // Unconfirmed: no card, because a card here would claim the app's hosts |
| 629 | // already read from Second when only one of them does. |
| 630 | status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}}) |
| 631 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| 632 | t.Fatalf("partial-mismatch move = %d -> %q:\n%s", status, loc, body) |
| 633 | } |
| 634 | notACard(t, body) |
| 635 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, |
| 636 | "routes disagree among themselves") { |
| 637 | t.Fatalf("no routes-disagree refusal flash:\n%s", page) |
| 638 | } |
| 639 | |
| 640 | // Confirmed, straight at the door: same refusal, and nothing moved. |
| 641 | status, loc, body = postGated(t, fx, c, fx.u("/apps/kass/move"), |
| 642 | url.Values{"csrf": {csrf}, "confirmed": {"1"}, "to": {"cdef"}}) |
| 643 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { |
| 644 | t.Fatalf("confirmed partial-mismatch move = %d -> %q:\n%s", status, loc, body) |
| 645 | } |
| 646 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, |
| 647 | "routes disagree among themselves") { |
| 648 | t.Fatalf("no routes-disagree refusal flash on the confirmed POST:\n%s", page) |
| 649 | } |
| 650 | if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| 651 | t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err) |
| 652 | } |
| 653 | if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has { |
| 654 | t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved to the minority account", has, err) |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | // ---- the Settings page ------------------------------------------------------ |
| 659 | |
| 660 | // The rename form is offered while the name is still only a record here, and |
| 661 | // replaced by the reason the moment the bucket knows it. |
| 662 | func TestSettingsPageRenameFormFollowsTheBucket(t *testing.T) { |
| 663 | fx := newConsoleFixture(t) |
| 664 | freshApp(t, fx, "sprout") |
| 665 | c := signInPaul(t, fx) |
| 666 | |
| 667 | status, _, body := get(t, c, fx.u("/apps/sprout/settings")) |
| 668 | if status != http.StatusOK { |
| 669 | t.Fatalf("settings page = %d:\n%s", status, body) |
| 670 | } |
| 671 | if !strings.Contains(body, `id="rename-to"`) || |
| 672 | !strings.Contains(body, `action="/a/bab/apps/sprout/rename"`) { |
| 673 | t.Fatalf("a never-shipped app is offered no rename form:\n%s", body) |
| 674 | } |
| 675 | |
| 676 | shipSomething(t, fx, "sprout") |
| 677 | _, _, body = get(t, c, fx.u("/apps/sprout/settings")) |
| 678 | if strings.Contains(body, `id="rename-to"`) { |
| 679 | t.Fatalf("the rename form survived the app's first ship:\n%s", body) |
| 680 | } |
| 681 | if !strings.Contains(body, "Renaming: This app has shipped") { |
| 682 | t.Fatalf("the page does not say why renaming is off:\n%s", body) |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | // Everything on this page that changes the app's identity is the owner's. A |
| 687 | // member sees the identity and the domains, and neither form. |
| 688 | func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) { |
| 689 | fx := newConsoleFixture(t) |
| 690 | freshApp(t, fx, "sprout") |
| 691 | // The owner's own view first, so the member-side absences below cannot |
| 692 | // pass vacuously: both forms ARE eligible to render in this exact state. |
| 693 | owner := signInPaul(t, fx) |
| 694 | _, _, obody := get(t, owner, fx.u("/apps/sprout/settings")) |
| 695 | _, _, odelete := get(t, owner, fx.u("/apps/sprout/settings/delete")) |
| 696 | if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(odelete, `id="delete-confirm"`) { |
| 697 | t.Fatalf("the owner is not offered both forms — the member checks below would be vacuous:\n%s", obody) |
| 698 | } |
| 699 | |
| 700 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 701 | t.Fatal(err) |
| 702 | } |
| 703 | fx.setUser("sam@keymail.dev") |
| 704 | cm := newBrowser(t) |
| 705 | signUp(t, fx, cm, "code-sam-settings") |
| 706 | status, _, body := get(t, cm, fx.u("/apps/sprout/settings")) |
| 707 | if status != http.StatusOK { |
| 708 | t.Fatalf("member settings page = %d, want 200:\n%s", status, body) |
| 709 | } |
| 710 | _, _, domainsBody := get(t, cm, fx.u("/apps/sprout/settings/domains")) |
| 711 | if !strings.Contains(domainsBody, "Domains & TLS") { |
| 712 | t.Fatalf("a member cannot see the page at all:\n%s", body) |
| 713 | } |
| 714 | if strings.Contains(body, `id="rename-to"`) || strings.Contains(body, "/rename\"") { |
| 715 | t.Errorf("a member is offered the rename form:\n%s", body) |
| 716 | } |
| 717 | _, _, deleteBody := get(t, cm, fx.u("/apps/sprout/settings/delete")) |
| 718 | if strings.Contains(deleteBody, `id="delete-confirm"`) || strings.Contains(deleteBody, `action="/a/bab/apps/sprout/delete"`) { |
| 719 | t.Errorf("a member is offered the delete form:\n%s", deleteBody) |
| 720 | } |
| 721 | } |
| 722 | |
| 723 | // ---- the store card and its destroy door ------------------------------------ |
| 724 | // |
| 725 | // Destroying a store is the only console action that destroys a customer's |
| 726 | // OBJECTS outside the app trash, and there is no trash for a bucket. So the |
| 727 | // ceremony is the purge door's, with one difference that matters: what a person |
| 728 | // types is the BUCKET name, because the bucket is the thing that stops |
| 729 | // existing — an app name typed here would confirm the wrong noun. |
| 730 | |
| 731 | // pendingKey puts the record into the half-finished rotation state: two keys |
| 732 | // open the bucket until the member finishes it, and the page must nag. |
| 733 | func pendingKey(t *testing.T, fx *consoleFixture, app, prev string) { |
| 734 | t.Helper() |
| 735 | ctx := context.Background() |
| 736 | rec, etag, err := objstore.Get(ctx, fx.rel, fx.sqid, app) |
| 737 | if err != nil || rec == nil || rec.Provisioned == nil { |
| 738 | t.Fatalf("read record: %+v %v", rec, err) |
| 739 | } |
| 740 | rec.Provisioned.PrevKeyID = prev |
| 741 | if _, err := objstore.Put(ctx, fx.rel, fx.sqid, app, rec, etag); err != nil { |
| 742 | t.Fatal(err) |
| 743 | } |
| 744 | } |
| 745 | |
| 746 | // stashedActions counts parked step-up actions — the evidence that a passkey |
| 747 | // was demanded. A refusal that gets this far has already cost a gesture. |
| 748 | func stashedActions(t *testing.T, fx *consoleFixture) int { |
| 749 | t.Helper() |
| 750 | var n int |
| 751 | if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&n); err != nil { |
| 752 | t.Fatal(err) |
| 753 | } |
| 754 | return n |
| 755 | } |
| 756 | |
| 757 | // The typed word is the bucket, checked BEFORE the passkey: a slip must cost a |
| 758 | // retry, never a gesture, and must destroy nothing. The app's own name is the |
| 759 | // most likely wrong answer, so it is the one under test. |
| 760 | func TestStoreDestroyRefusesAnythingButTheBucketName(t *testing.T) { |
| 761 | fx := newConsoleFixture(t) |
| 762 | fake := armStoreBackend(t, fx) |
| 763 | bucket := seedStore(t, fx, fake, "kass", false) |
| 764 | c := signInPaul(t, fx) |
| 765 | csrf := sessionToken(t, fx, c) |
| 766 | |
| 767 | for _, typed := range []string{"kass", "", "carlos-bab-kas"} { |
| 768 | status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"), |
| 769 | url.Values{"csrf": {csrf}, "confirm": {typed}}) |
| 770 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 771 | t.Fatalf("confirm %q demanded a passkey before it was even checked (-> %q)", typed, loc) |
| 772 | } |
| 773 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { |
| 774 | t.Fatalf("confirm %q = %d -> %q, want the settings flash:\n%s", typed, status, loc, body) |
| 775 | } |
| 776 | if n := stashedActions(t, fx); n != 0 { |
| 777 | t.Fatalf("confirm %q parked %d pending actions — the passkey was asked for anyway", typed, n) |
| 778 | } |
| 779 | estateStanding(t, fx, fake, "kass", bucket, "a mistyped destroy") |
| 780 | if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { |
| 781 | t.Fatalf("a mistyped destroy touched the record: %+v", rec) |
| 782 | } |
| 783 | } |
| 784 | if _, _, page := get(t, c, fx.u("/apps/kass/settings/storage")); !strings.Contains(page, bucket) { |
| 785 | t.Fatalf("the refusal does not tell the owner what to type:\n%s", page) |
| 786 | } |
| 787 | } |
| 788 | |
| 789 | // The whole ceremony: the bucket typed, the passkey, and then everything — |
| 790 | // objects, bucket, principal, key and the record — is gone. The serial moves |
| 791 | // because the delivered variables were unset: every box serving this app is |
| 792 | // running with credentials that no longer work, and must converge. |
| 793 | func TestStoreDestroyDestroysTheEstateAndAnnounces(t *testing.T) { |
| 794 | fx := newConsoleFixture(t) |
| 795 | fake := armStoreBackend(t, fx) |
| 796 | bucket := seedStore(t, fx, fake, "kass", false) |
| 797 | c := signInPaul(t, fx) |
| 798 | csrf := sessionToken(t, fx, c) |
| 799 | before := serial(t, fx) |
| 800 | |
| 801 | // Without the passkey, nothing moves. |
| 802 | status, loc, _ := postForm(t, c, fx.u("/apps/kass/store/destroy"), |
| 803 | url.Values{"csrf": {csrf}, "confirm": {bucket}}) |
| 804 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 805 | t.Fatalf("un-stepped-up destroy = %d -> %q, want a redirect into Keymail", status, loc) |
| 806 | } |
| 807 | estateStanding(t, fx, fake, "kass", bucket, "a destroy awaiting its passkey") |
| 808 | |
| 809 | status, loc, body := completeStepUp(t, fx, c, loc) |
| 810 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { |
| 811 | t.Fatalf("resumed destroy = %d -> %q:\n%s", status, loc, body) |
| 812 | } |
| 813 | estateGone(t, fake, "a completed destroy") |
| 814 | if rec := storedRecord(t, fx, "kass"); rec != nil { |
| 815 | t.Fatalf("the store record survived its own destruction: %+v", rec) |
| 816 | } |
| 817 | if got := serial(t, fx); got <= before { |
| 818 | t.Fatalf("serial after a destroy = %d, want a bump from %d — the boxes must converge on the unset variables", got, before) |
| 819 | } |
| 820 | auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", bucket) |
| 821 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, bucket) { |
| 822 | t.Fatalf("the destroy flash does not name what was destroyed:\n%s", page) |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | // TestStoreDestroyLeavesTheEmailEstateAlone: R3, review follow-up. A |
| 827 | // previous fix wave wired mail.Teardown into THIS door — but this door's |
| 828 | // confirm word is the bucket name, its button says "Destroy store", and its |
| 829 | // success flash (asserted above) names only the object store. Nothing here |
| 830 | // ever told the owner that confirming would also destroy the SES identity, |
| 831 | // the IAM principal, delivered credentials, published DNS and — globally, |
| 832 | // irreversibly — the domain claim. Consent for the store is not consent for |
| 833 | // email; ownership.go's handlePurgeApp (the whole-app delete) is the door |
| 834 | // that destroys both, and says so. |
| 835 | // |
| 836 | // The proof is the claim's own semantics (mirroring |
| 837 | // TestPurgeReleasesTheDomainClaim in deletion_test.go), not an internal peek |
| 838 | // at bucket keys: a SECOND, unrelated account trying to claim the SAME |
| 839 | // domain is refused with ErrDomainClaimed both before and after the store |
| 840 | // destroy — if this door released it, the second account's EnsureDomain |
| 841 | // would start succeeding. |
| 842 | func TestStoreDestroyLeavesTheEmailEstateAlone(t *testing.T) { |
| 843 | fx := newConsoleFixture(t) |
| 844 | fake := armStoreBackend(t, fx) |
| 845 | bucket := seedStore(t, fx, fake, "kass", false) |
| 846 | |
| 847 | mfake := mail.NewMemProvisioner("1") |
| 848 | writer := dnsrec.NewMemWriter() |
| 849 | fx.srv.cfg.MailProvisioner = mfake |
| 850 | fx.srv.cfg.DNSWriter = writer |
| 851 | dep := mail.Deployment{Region: "eu-west-1"} // no PlatformSuffix, matching |
| 852 | // TestPurgeReleasesTheDomainClaim: keeps this about the claim gate |
| 853 | // alone, not the separate platform-name-ownership gate. |
| 854 | ctx := context.Background() |
| 855 | now := time.Now() |
| 856 | domain := "store-destroy-claim.example.com" |
| 857 | // Deliberately no mail.Declare/Enable — kass has a domain record (and |
| 858 | // the claim it holds) but no Provisioned credential, exactly the shape |
| 859 | // `carlos email domains add` alone leaves. If this door's old |
| 860 | // mail.Teardown wiring were still here, it would find and release this |
| 861 | // claim too — mail.Teardown traces the domain-referrer set, not just a |
| 862 | // delivered credential. |
| 863 | if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, fx.sqid, "kass", domain, now); err != nil { |
| 864 | t.Fatalf("EnsureDomain for kass: %v", err) |
| 865 | } |
| 866 | if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) { |
| 867 | t.Fatalf("a second account's EnsureDomain before the destroy = %v, want ErrDomainClaimed", err) |
| 868 | } |
| 869 | |
| 870 | c := signInPaul(t, fx) |
| 871 | csrf := sessionToken(t, fx, c) |
| 872 | status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"), |
| 873 | url.Values{"csrf": {csrf}, "confirm": {bucket}}) |
| 874 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { |
| 875 | t.Fatalf("store destroy = %d -> %q:\n%s", status, loc, body) |
| 876 | } |
| 877 | estateGone(t, fake, "a store destroy that must leave email alone") |
| 878 | if rec := storedRecord(t, fx, "kass"); rec != nil { |
| 879 | t.Fatalf("the store record survived its own destruction: %+v", rec) |
| 880 | } |
| 881 | |
| 882 | if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) { |
| 883 | t.Fatalf("the domain claim was released by a store destroy: err = %v, want it still ErrDomainClaimed", err) |
| 884 | } |
| 885 | } |
| 886 | |
| 887 | // A declaration is a request nobody has acted on: no bucket, no key, nothing to |
| 888 | // spend backend authority on. So a console with no backend at all may still |
| 889 | // withdraw it — and the word to type is the APP's name, because that is the |
| 890 | // only noun this record has. |
| 891 | func TestStoreDestroyOfADeclarationNeedsNoBackend(t *testing.T) { |
| 892 | fx := newConsoleFixture(t) |
| 893 | putRecord(t, fx, "kass", &objstore.Record{ |
| 894 | Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: time.Now(), By: "paul@keymail.dev"}, |
| 895 | }) |
| 896 | fx.srv.cfg.StoreProvisioner = nil |
| 897 | c := signInPaul(t, fx) |
| 898 | csrf := sessionToken(t, fx, c) |
| 899 | |
| 900 | status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"), |
| 901 | url.Values{"csrf": {csrf}, "confirm": {"kass"}}) |
| 902 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { |
| 903 | t.Fatalf("destroy of a declaration = %d -> %q:\n%s", status, loc, body) |
| 904 | } |
| 905 | if rec := storedRecord(t, fx, "kass"); rec != nil { |
| 906 | t.Fatalf("the declaration survived: %+v", rec) |
| 907 | } |
| 908 | auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "declaration") |
| 909 | } |
| 910 | |
| 911 | // A provisioned store on a backend-less console is the other half of that rule: |
| 912 | // the bucket and the key are real and this console has no authority over them, |
| 913 | // so it refuses and names the variable that would give it some — before the |
| 914 | // passkey, since no gesture could change the answer. |
| 915 | func TestStoreDestroyRefusesAnEstateWithNoBackend(t *testing.T) { |
| 916 | fx := newConsoleFixture(t) |
| 917 | fake := armStoreBackend(t, fx) |
| 918 | bucket := seedStore(t, fx, fake, "kass", false) |
| 919 | fx.srv.cfg.StoreProvisioner = nil |
| 920 | c := signInPaul(t, fx) |
| 921 | csrf := sessionToken(t, fx, c) |
| 922 | |
| 923 | status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"), |
| 924 | url.Values{"csrf": {csrf}, "confirm": {bucket}}) |
| 925 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { |
| 926 | t.Fatalf("backend-less destroy = %d -> %q:\n%s", status, loc, body) |
| 927 | } |
| 928 | if n := stashedActions(t, fx); n != 0 { |
| 929 | t.Fatalf("a refusal nothing could change still cost a passkey (%d parked actions)", n) |
| 930 | } |
| 931 | estateStanding(t, fx, fake, "kass", bucket, "a backend-less destroy") |
| 932 | if rec := storedRecord(t, fx, "kass"); rec == nil { |
| 933 | t.Fatal("the record was deleted with the estate still standing") |
| 934 | } |
| 935 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "CARLOS_STORE_BACKEND") { |
| 936 | t.Fatalf("the refusal does not name the backend variable:\n%s", page) |
| 937 | } |
| 938 | } |
| 939 | |
| 940 | // A backend that refuses mid-walk must leave the record naming the estate: the |
| 941 | // record is the ONLY thing that names a bucket and a principal, so a door that |
| 942 | // deleted it on a failed teardown would leave a paid, credentialed estate |
| 943 | // nobody can find. The person is told it is still there, which is also the |
| 944 | // instruction — re-entering the door resumes. |
| 945 | func TestStoreDestroyFailureLeavesTheRecordNamingTheEstate(t *testing.T) { |
| 946 | fx := newConsoleFixture(t) |
| 947 | fake := armStoreBackend(t, fx) |
| 948 | bucket := seedStore(t, fx, fake, "kass", false) |
| 949 | fake.FailNext["EmptyBucket"] = errors.New("backend unreachable") |
| 950 | c := signInPaul(t, fx) |
| 951 | csrf := sessionToken(t, fx, c) |
| 952 | |
| 953 | status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"), |
| 954 | url.Values{"csrf": {csrf}, "confirm": {bucket}}) |
| 955 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { |
| 956 | t.Fatalf("failed destroy = %d -> %q:\n%s", status, loc, body) |
| 957 | } |
| 958 | estateStanding(t, fx, fake, "kass", bucket, "a failed destroy") |
| 959 | if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { |
| 960 | t.Fatalf("a failed destroy deleted the record naming the estate: %+v", rec) |
| 961 | } |
| 962 | if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "still there") { |
| 963 | t.Fatalf("the failure does not say the store survived:\n%s", page) |
| 964 | } |
| 965 | } |
| 966 | |
| 967 | // Destroying is the account owner's. A member meets the same 404 a stranger |
| 968 | // does — before any passkey redirect could concede the app, or the store, |
| 969 | // exists. |
| 970 | func TestStoreDestroyIsOwnerOnly(t *testing.T) { |
| 971 | fx := newConsoleFixture(t) |
| 972 | fake := armStoreBackend(t, fx) |
| 973 | bucket := seedStore(t, fx, fake, "kass", false) |
| 974 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 975 | t.Fatal(err) |
| 976 | } |
| 977 | fx.setUser("sam@keymail.dev") |
| 978 | c := newBrowser(t) |
| 979 | signUp(t, fx, c, "code-sam-store-destroy") |
| 980 | csrf := sessionToken(t, fx, c) |
| 981 | |
| 982 | status, _, memberBody := postForm(t, c, fx.u("/apps/kass/store/destroy"), |
| 983 | url.Values{"csrf": {csrf}, "confirm": {bucket}}) |
| 984 | if status != http.StatusNotFound { |
| 985 | t.Fatalf("member destroying a store = %d, want 404", status) |
| 986 | } |
| 987 | strangerBody := strangerRefusal(t, fx, "code-zoe-store-destroy", |
| 988 | fx.u("/apps/kass/store/destroy"), url.Values{"confirm": {bucket}}) |
| 989 | if memberBody != strangerBody { |
| 990 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 991 | } |
| 992 | estateStanding(t, fx, fake, "kass", bucket, "a member's refused destroy") |
| 993 | } |
| 994 | |
| 995 | // The card is the member's only sight of a store they cannot reach with box |
| 996 | // commands: what prefix it delivers under, which bucket, which key and how old |
| 997 | // that key is — labelled as the KEY's age, because a grant repair moves that |
| 998 | // timestamp and calling it "provisioned" would be a lie the page tells forever. |
| 999 | func TestSettingsPageShowsTheStoreCard(t *testing.T) { |
| 1000 | fx := newConsoleFixture(t) |
| 1001 | fake := armStoreBackend(t, fx) |
| 1002 | bucket := seedStore(t, fx, fake, "kass", false) |
| 1003 | pendingKey(t, fx, "kass", "MEMKEY-OLD") |
| 1004 | keyID := storedRecord(t, fx, "kass").Provisioned.KeyID |
| 1005 | c := signInPaul(t, fx) |
| 1006 | |
| 1007 | status, _, body := get(t, c, fx.u("/apps/kass/settings/storage")) |
| 1008 | if status != http.StatusOK { |
| 1009 | t.Fatalf("settings page = %d:\n%s", status, body) |
| 1010 | } |
| 1011 | for _, want := range []string{ |
| 1012 | objstore.DefaultEnvPrefix, // the env set the app reads |
| 1013 | bucket, // the bucket its data is in |
| 1014 | keyID, // the key that opens it |
| 1015 | "last credential change", // what the age actually means |
| 1016 | "carlos store rotate --finish --app kass", // the half-done rotation nag |
| 1017 | `id="store-destroy-confirm"`, // the destroy form |
| 1018 | } { |
| 1019 | if !strings.Contains(body, want) { |
| 1020 | t.Fatalf("the store card is missing %q:\n%s", want, body) |
| 1021 | } |
| 1022 | } |
| 1023 | if strings.Contains(body, "Provisioned</th>") { |
| 1024 | t.Fatalf("the card labels the key timestamp as a provisioning date:\n%s", body) |
| 1025 | } |
| 1026 | |
| 1027 | // An app nobody has declared a store for gets the one line that tells them |
| 1028 | // how to ask for one — and no destroy form for a store that isn't there. |
| 1029 | freshApp(t, fx, "sprout") |
| 1030 | _, _, body = get(t, c, fx.u("/apps/sprout/settings/storage")) |
| 1031 | if !strings.Contains(body, "carlos store create") { |
| 1032 | t.Fatalf("an undeclared app is not told how to ask for a store:\n%s", body) |
| 1033 | } |
| 1034 | if strings.Contains(body, `id="store-destroy-confirm"`) { |
| 1035 | t.Fatalf("an app with no store is offered a destroy form:\n%s", body) |
| 1036 | } |
| 1037 | } |
| 1038 | |
| 1039 | // ---- the domain attach/detach section of Settings (#82 Task 5) ------------- |
| 1040 | // |
| 1041 | // The honesty text ("attached where the app runs — on its box, by an |
| 1042 | // operator") is retired now that this account's own door does the attaching |
| 1043 | // (appdomains.go, Task 4): the page must offer that door's form instead of a |
| 1044 | // box command. And a claim this app holds must show up here the moment it |
| 1045 | // exists, not only once a registry route has caught up with it — which, |
| 1046 | // for an ALIAS claim, is never: domainattach.go's own comment is that |
| 1047 | // minting a route row for an alias is exactly the #129/#148 bare-alias |
| 1048 | // class this design avoids. |
| 1049 | |
| 1050 | // TestSettingsPageOffersAttachFormNotBoxInstructions is the honesty-text |
| 1051 | // retirement itself. |
| 1052 | func TestSettingsPageOffersAttachFormNotBoxInstructions(t *testing.T) { |
| 1053 | fx := newConsoleFixture(t) |
| 1054 | freshApp(t, fx, "sprout") |
| 1055 | c := signInPaul(t, fx) |
| 1056 | |
| 1057 | status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains")) |
| 1058 | if status != http.StatusOK { |
| 1059 | t.Fatalf("settings page = %d:\n%s", status, body) |
| 1060 | } |
| 1061 | if strings.Contains(body, "carlos add --host") { |
| 1062 | t.Fatalf("the settings page still tells the owner to attach on the box:\n%s", body) |
| 1063 | } |
| 1064 | if !strings.Contains(body, `action="/a/bab/apps/sprout/domains/attach"`) { |
| 1065 | t.Fatalf("the settings page does not post to its own attach door:\n%s", body) |
| 1066 | } |
| 1067 | } |
| 1068 | |
| 1069 | // TestSettingsPageShowsAnUnroutedClaimAsAttaching exercises the gap the |
| 1070 | // honesty text used to paper over: kass already has a ready route, so |
| 1071 | // attaching docs.example.com to it takes the ALIAS branch, which never gets |
| 1072 | // a registry row — the claim must still render, as its own placeholder row, |
| 1073 | // not vanish until a route exists to join it to. |
| 1074 | func TestSettingsPageShowsAnUnroutedClaimAsAttaching(t *testing.T) { |
| 1075 | const host = "docs.example.com" |
| 1076 | fx, c, _ := domainAttachedFixture(t, "kass", host) |
| 1077 | |
| 1078 | status, _, body := get(t, c, fx.u("/apps/kass/settings/domains")) |
| 1079 | if status != http.StatusOK { |
| 1080 | t.Fatalf("settings page = %d:\n%s", status, body) |
| 1081 | } |
| 1082 | if !strings.Contains(body, host) { |
| 1083 | t.Fatalf("an attached claim with no route yet does not appear on its own Settings page:\n%s", body) |
| 1084 | } |
| 1085 | if !strings.Contains(body, "custom · attaching") { |
| 1086 | t.Fatalf("the claim-only row does not say it's still attaching:\n%s", body) |
| 1087 | } |
| 1088 | } |
| 1089 | |
| 1090 | // TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform is the review |
| 1091 | // fix: readyRouteHost (domainattach.go) skips Platform rows when deciding |
| 1092 | // whether an attach can alias, so an app whose only route is its own |
| 1093 | // platform mint takes the first-route branch server-side and needs the |
| 1094 | // channel field exactly as a never-shipped app does — `len(Detail.Routes) |
| 1095 | // == 0` would wrongly hide it, because the platform route makes that slice |
| 1096 | // non-empty. |
| 1097 | func TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform(t *testing.T) { |
| 1098 | fx := newConsoleFixture(t) |
| 1099 | freshApp(t, fx, "sprout") |
| 1100 | fx.addRoute(t, registry.Route{Host: "sprout.bab.oncarlos.com", Kind: "static", Status: "ready", |
| 1101 | App: "sprout", Channel: "stable", Platform: true}) |
| 1102 | c := signInPaul(t, fx) |
| 1103 | |
| 1104 | status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains")) |
| 1105 | if status != http.StatusOK { |
| 1106 | t.Fatalf("settings page = %d:\n%s", status, body) |
| 1107 | } |
| 1108 | if !strings.Contains(body, `id="attach-channel"`) { |
| 1109 | t.Fatalf("an app whose only route is its platform mint is not offered the channel field:\n%s", body) |
| 1110 | } |
| 1111 | } |
| 1112 | |
| 1113 | // TestAssembleSettingsListsRoutedDomainsWithoutABucket is the other review |
| 1114 | // fix: assembleSettings used to return before sv.Domains was ever set on a |
| 1115 | // bucketless console (s.cfg.Releases == nil), so a routed app's own domain |
| 1116 | // would vanish from Settings.Domains the moment there was no bucket to |
| 1117 | // check the never-shipped rename gate against. |
| 1118 | // |
| 1119 | // This calls assembleSettings directly rather than through the HTTP page: |
| 1120 | // a bucketless console panics inside Assemble itself (assembleChannels, |
| 1121 | // view.go, calls release.Store.List on every app-page tab, Settings |
| 1122 | // included, with no nil guard) — a real, pre-existing gap, but one that |
| 1123 | // reaches every app page and is not Task 5's Settings-only bug. Fixing |
| 1124 | // assembleSettings's own early return is; isolating it here proves that fix |
| 1125 | // without the unit test tripping over the wider, separate one. |
| 1126 | func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) { |
| 1127 | fx := newConsoleFixture(t) |
| 1128 | fx.srv.cfg.Releases = nil |
| 1129 | acct, err := fx.st.AccountBySqid(fx.sqid) |
| 1130 | if err != nil || acct == nil { |
| 1131 | t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| 1132 | } |
| 1133 | detail := &AppView{ |
| 1134 | App: "kass", |
| 1135 | Domains: []DomainView{{Domain: "kass.carlosapp.dev", Type: "custom"}}, |
| 1136 | } |
| 1137 | p := &page{AppName: "kass", Detail: detail} |
| 1138 | |
| 1139 | sv := fx.srv.assembleSettings(context.Background(), p, acct) |
| 1140 | if !sv.NoBucket || sv.StoreUnavailable || sv.MailUnavailable || sv.FeaturesUnavailable { |
| 1141 | t.Fatalf("bucketless settings flags = NoBucket %v, unavailable %v/%v/%v", sv.NoBucket, |
| 1142 | sv.StoreUnavailable, sv.MailUnavailable, sv.FeaturesUnavailable) |
| 1143 | } |
| 1144 | if len(sv.Domains) != 1 || sv.Domains[0].Domain != "kass.carlosapp.dev" { |
| 1145 | t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains) |
| 1146 | } |
| 1147 | } |
| 1148 | |
| 1149 | // ---- Settings tab: the Email card (issue #240) ------------------------------ |
| 1150 | // |
| 1151 | // mailCard is object storage's sibling (storeCard, above) — same declare -> |
| 1152 | // grant -> provision -> rotate shape, same error discipline: a read failure |
| 1153 | // returns nil so the page says nothing about email at all rather than |
| 1154 | // guessing, and an absent record returns a zero card, the shape the |
| 1155 | // empty-state line renders from. Available is the one field that does NOT |
| 1156 | // come from the record at all: it is s.cfg.MailProvisioner != nil, read |
| 1157 | // before the record is, because it decides which of the two empty-state |
| 1158 | // sentences a zero card gets — and a deployment with no provisioner must |
| 1159 | // never be told to run a command that mailProvisionerGate would 404. |
| 1160 | |
| 1161 | // putMailRecord seeds apps/<acct>/<app>/email.json directly — the state the |
| 1162 | // card has to report on and the walk functions (Declare/ApplyGrant/Enable/ |
| 1163 | // SetPaused) would normally reach through their own doors. |
| 1164 | func putMailRecord(t *testing.T, fx *consoleFixture, app string, rec *mail.Record) { |
| 1165 | t.Helper() |
| 1166 | if _, err := mail.Put(context.Background(), fx.rel, fx.sqid, app, rec, ""); err != nil { |
| 1167 | t.Fatalf("seed mail record for %s: %v", app, err) |
| 1168 | } |
| 1169 | } |
| 1170 | |
| 1171 | // mailCardAccount is the fixture account as mailCard's Account parameter — |
| 1172 | // only Sqid is read, so a bare literal is enough; AccountBySqid's roundtrip |
| 1173 | // through the console database would prove nothing extra. |
| 1174 | func mailCardAccount(fx *consoleFixture) *Account { return &Account{Sqid: fx.sqid} } |
| 1175 | |
| 1176 | func TestMailCardStates(t *testing.T) { |
| 1177 | now := time.Now() |
| 1178 | cases := []struct { |
| 1179 | name string |
| 1180 | app string |
| 1181 | rec *mail.Record // nil = write nothing |
| 1182 | provisionerNil bool |
| 1183 | check func(t *testing.T, card *MailCard) |
| 1184 | }{ |
| 1185 | { |
| 1186 | name: "absent record", |
| 1187 | app: "mail-absent", |
| 1188 | check: func(t *testing.T, card *MailCard) { |
| 1189 | if card == nil { |
| 1190 | t.Fatal("absent record with no read error returned nil, want a zero card") |
| 1191 | } |
| 1192 | if !card.Available { |
| 1193 | t.Fatal("Available = false with a MailProvisioner configured") |
| 1194 | } |
| 1195 | if card.Declared || card.Granted || card.Provisioned || len(card.Standalone) != 0 { |
| 1196 | t.Fatalf("absent record produced a non-zero card: %+v", card) |
| 1197 | } |
| 1198 | }, |
| 1199 | }, |
| 1200 | { |
| 1201 | name: "declared only", |
| 1202 | app: "mail-declared", |
| 1203 | rec: &mail.Record{Declared: &mail.Declared{ |
| 1204 | EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now, By: "paul@keymail.dev", |
| 1205 | }}, |
| 1206 | check: func(t *testing.T, card *MailCard) { |
| 1207 | if !card.Declared || card.EnvPrefix != "CARLOS_SMTP" || card.From != "hi@example.com" { |
| 1208 | t.Fatalf("declared-only card = %+v", card) |
| 1209 | } |
| 1210 | if card.Granted || card.Provisioned { |
| 1211 | t.Fatalf("declared-only card claims more than declared: %+v", card) |
| 1212 | } |
| 1213 | }, |
| 1214 | }, |
| 1215 | { |
| 1216 | name: "granted", |
| 1217 | app: "mail-granted", |
| 1218 | rec: &mail.Record{ |
| 1219 | Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| 1220 | Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, Source: "tier:free", At: now}, |
| 1221 | }, |
| 1222 | check: func(t *testing.T, card *MailCard) { |
| 1223 | if !card.Granted || card.DailyCap != 500 { |
| 1224 | t.Fatalf("granted card = %+v", card) |
| 1225 | } |
| 1226 | if len(card.Domains) != 1 || card.Domains[0] != "example.com" { |
| 1227 | t.Fatalf("granted card domains = %+v, want [example.com]", card.Domains) |
| 1228 | } |
| 1229 | if card.Provisioned { |
| 1230 | t.Fatalf("granted-only card claims provisioned: %+v", card) |
| 1231 | } |
| 1232 | }, |
| 1233 | }, |
| 1234 | { |
| 1235 | name: "provisioned", |
| 1236 | app: "mail-provisioned", |
| 1237 | rec: &mail.Record{ |
| 1238 | Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| 1239 | Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, At: now}, |
| 1240 | Provisioned: &mail.Provisioned{KeyID: "AKIA1", Active: true, At: now.Add(-2 * time.Hour)}, |
| 1241 | }, |
| 1242 | check: func(t *testing.T, card *MailCard) { |
| 1243 | if !card.Provisioned || card.KeyID != "AKIA1" { |
| 1244 | t.Fatalf("provisioned card = %+v", card) |
| 1245 | } |
| 1246 | if card.KeyAge == "" { |
| 1247 | t.Fatal("provisioned card has no KeyAge") |
| 1248 | } |
| 1249 | if card.Paused || card.PendingFinish { |
| 1250 | t.Fatalf("a healthy provisioned card reads paused or pending-finish: %+v", card) |
| 1251 | } |
| 1252 | }, |
| 1253 | }, |
| 1254 | { |
| 1255 | name: "mid-rotation", |
| 1256 | app: "mail-rotating", |
| 1257 | rec: &mail.Record{ |
| 1258 | Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| 1259 | Provisioned: &mail.Provisioned{KeyID: "AKIA2", PrevKeyID: "AKIA1", Active: true, At: now}, |
| 1260 | }, |
| 1261 | check: func(t *testing.T, card *MailCard) { |
| 1262 | if !card.PendingFinish { |
| 1263 | t.Fatalf("a record with a PrevKeyID did not set PendingFinish: %+v", card) |
| 1264 | } |
| 1265 | }, |
| 1266 | }, |
| 1267 | { |
| 1268 | name: "paused-cap", |
| 1269 | app: "mail-paused-cap", |
| 1270 | rec: &mail.Record{ |
| 1271 | Provisioned: &mail.Provisioned{KeyID: "AKIA3", Active: false, PauseKind: mail.PauseKindCap, At: now}, |
| 1272 | }, |
| 1273 | check: func(t *testing.T, card *MailCard) { |
| 1274 | if !card.Paused || card.PauseKind != mail.PauseKindCap { |
| 1275 | t.Fatalf("paused-cap card = %+v", card) |
| 1276 | } |
| 1277 | }, |
| 1278 | }, |
| 1279 | { |
| 1280 | name: "paused-rate", |
| 1281 | app: "mail-paused-rate", |
| 1282 | rec: &mail.Record{ |
| 1283 | Provisioned: &mail.Provisioned{KeyID: "AKIA4", Active: false, PauseKind: mail.PauseKindRate, At: now}, |
| 1284 | }, |
| 1285 | check: func(t *testing.T, card *MailCard) { |
| 1286 | if !card.Paused || card.PauseKind != mail.PauseKindRate { |
| 1287 | t.Fatalf("paused-rate card = %+v", card) |
| 1288 | } |
| 1289 | }, |
| 1290 | }, |
| 1291 | { |
| 1292 | name: "paused-manual", |
| 1293 | app: "mail-paused-manual", |
| 1294 | rec: &mail.Record{ |
| 1295 | Provisioned: &mail.Provisioned{KeyID: "AKIA5", Active: false, PauseKind: "", At: now}, |
| 1296 | }, |
| 1297 | check: func(t *testing.T, card *MailCard) { |
| 1298 | if !card.Paused || card.PauseKind != "" { |
| 1299 | t.Fatalf("paused-manual card = %+v", card) |
| 1300 | } |
| 1301 | }, |
| 1302 | }, |
| 1303 | { |
| 1304 | name: "with standalone credentials", |
| 1305 | app: "mail-standalone", |
| 1306 | rec: &mail.Record{ |
| 1307 | Standalone: []*mail.Standalone{ |
| 1308 | {Name: "mailgun-fallback", KeyID: "AKIASTAND", At: now.Add(-24 * time.Hour), By: "paul@keymail.dev"}, |
| 1309 | }, |
| 1310 | }, |
| 1311 | check: func(t *testing.T, card *MailCard) { |
| 1312 | if len(card.Standalone) != 1 { |
| 1313 | t.Fatalf("standalone card = %+v", card) |
| 1314 | } |
| 1315 | row := card.Standalone[0] |
| 1316 | if row.Name != "mailgun-fallback" || row.KeyID != "AKIASTAND" || row.Age == "" { |
| 1317 | t.Fatalf("standalone row = %+v", row) |
| 1318 | } |
| 1319 | }, |
| 1320 | }, |
| 1321 | { |
| 1322 | name: "provisioner nil", |
| 1323 | app: "mail-no-provisioner", |
| 1324 | provisionerNil: true, |
| 1325 | check: func(t *testing.T, card *MailCard) { |
| 1326 | if card == nil { |
| 1327 | t.Fatal("provisioner-nil, absent record returned nil, want a zero card") |
| 1328 | } |
| 1329 | if card.Available { |
| 1330 | t.Fatal("Available = true with no MailProvisioner configured") |
| 1331 | } |
| 1332 | if card.Declared || card.Granted || card.Provisioned { |
| 1333 | t.Fatalf("provisioner-nil card is not otherwise zero: %+v", card) |
| 1334 | } |
| 1335 | }, |
| 1336 | }, |
| 1337 | } |
| 1338 | |
| 1339 | for _, tc := range cases { |
| 1340 | tc := tc |
| 1341 | t.Run(tc.name, func(t *testing.T) { |
| 1342 | fx := newConsoleFixture(t) |
| 1343 | if !tc.provisionerNil { |
| 1344 | fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| 1345 | } |
| 1346 | if tc.rec != nil { |
| 1347 | putMailRecord(t, fx, tc.app, tc.rec) |
| 1348 | } |
| 1349 | card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), tc.app) |
| 1350 | tc.check(t, card) |
| 1351 | }) |
| 1352 | } |
| 1353 | } |
| 1354 | |
| 1355 | // unreadableMailStore breaks exactly the email record's read — the state a |
| 1356 | // bucket hiccup (or a permissions gap on this one object) leaves behind. |
| 1357 | type unreadableMailStore struct{ release.Store } |
| 1358 | |
| 1359 | func (u unreadableMailStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 1360 | if strings.HasSuffix(key, "/email.json") { |
| 1361 | return nil, "", errors.New("AccessDenied: s3://carlos-releases/apps/.../email.json request id XYZ") |
| 1362 | } |
| 1363 | return u.Store.Get(ctx, key) |
| 1364 | } |
| 1365 | |
| 1366 | // TestMailCardReadErrorReturnsNilNotAZeroCard is storeCard's own discipline, |
| 1367 | // carried over: "could not read the record" and "there is genuinely no |
| 1368 | // email" must never render the same way. A zero card here would tell a |
| 1369 | // member "no email for this app yet, get it with carlos email enable" on a |
| 1370 | // page that could not actually confirm that — mailCard must instead return |
| 1371 | // nil, which the template renders as nothing at all about email. |
| 1372 | func TestMailCardReadErrorReturnsNilNotAZeroCard(t *testing.T) { |
| 1373 | fx := newConsoleFixture(t) |
| 1374 | fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| 1375 | fx.srv.cfg.Releases = unreadableMailStore{fx.rel} |
| 1376 | |
| 1377 | card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), "kass") |
| 1378 | if card != nil { |
| 1379 | t.Fatalf("a read failure returned %+v, want nil", card) |
| 1380 | } |
| 1381 | } |
| 1382 | |
| 1383 | // TestSettingsPageShowsTheEmailCard is the render half: a card field the |
| 1384 | // template never reads does not exist for the member looking at the page, |
| 1385 | // and the paused state above all else must reach the HTML — a paused app is |
| 1386 | // sending nothing, and this must be impossible to miss. |
| 1387 | func TestSettingsPageShowsTheEmailCard(t *testing.T) { |
| 1388 | fx := newConsoleFixture(t) |
| 1389 | fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| 1390 | putMailRecord(t, fx, "kass", &mail.Record{ |
| 1391 | Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@kass.example.com", At: time.Now()}, |
| 1392 | Grant: &mail.GrantBlock{Domains: []string{"kass.example.com"}, DailyCap: 500, At: time.Now()}, |
| 1393 | Provisioned: &mail.Provisioned{ |
| 1394 | KeyID: "AKIAPAUSED", Active: false, PauseKind: mail.PauseKindRate, At: time.Now(), |
| 1395 | }, |
| 1396 | Standalone: []*mail.Standalone{{Name: "legacy-notifier", KeyID: "AKIASTAND1", At: time.Now()}}, |
| 1397 | }) |
| 1398 | c := signInPaul(t, fx) |
| 1399 | |
| 1400 | status, _, body := get(t, c, fx.u("/apps/kass/settings/email")) |
| 1401 | if status != http.StatusOK { |
| 1402 | t.Fatalf("settings page = %d:\n%s", status, body) |
| 1403 | } |
| 1404 | for _, want := range []string{ |
| 1405 | "CARLOS_SMTP", // the env prefix |
| 1406 | "hi@kass.example.com", // the From address |
| 1407 | "kass.example.com", // the granted sending domain |
| 1408 | "AKIAPAUSED", // the SMTP username |
| 1409 | "AKIASTAND1", // the standalone credential's key id |
| 1410 | "legacy-notifier", // the standalone credential's name |
| 1411 | "Sending is paused", // impossible to miss |
| 1412 | "does not clear itself", // the rate-pause distinction from a cap pause |
| 1413 | } { |
| 1414 | if !strings.Contains(body, want) { |
| 1415 | t.Fatalf("the email card is missing %q:\n%s", want, body) |
| 1416 | } |
| 1417 | } |
| 1418 | |
| 1419 | // An app nobody has declared email for, on a console WITH a provisioner, |
| 1420 | // gets the command that can actually run. |
| 1421 | freshApp(t, fx, "sprout") |
| 1422 | _, _, body = get(t, c, fx.u("/apps/sprout/settings/email")) |
| 1423 | if !strings.Contains(body, "carlos email enable --app sprout") { |
| 1424 | t.Fatalf("an undeclared app is not told how to ask for email:\n%s", body) |
| 1425 | } |
| 1426 | } |
| 1427 | |
| 1428 | // TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner is the |
| 1429 | // judgement call the brief calls out: a deployment with no MailProvisioner |
| 1430 | // must never suggest a command that mailProvisionerGate would 404 — |
| 1431 | // suggesting `carlos email enable` there would tell the member to run |
| 1432 | // something that cannot work. |
| 1433 | func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) { |
| 1434 | fx := newConsoleFixture(t) |
| 1435 | fx.srv.cfg.MailProvisioner = nil |
| 1436 | c := signInPaul(t, fx) |
| 1437 | |
| 1438 | status, _, body := get(t, c, fx.u("/apps/kass/settings/email")) |
| 1439 | if status != http.StatusOK { |
| 1440 | t.Fatalf("settings page = %d:\n%s", status, body) |
| 1441 | } |
| 1442 | if !strings.Contains(body, "not available on this deployment") { |
| 1443 | t.Fatalf("a provisioner-less console does not say email is unavailable:\n%s", body) |
| 1444 | } |
| 1445 | if strings.Contains(body, "carlos email enable") { |
| 1446 | t.Fatalf("a provisioner-less console still suggests a command that would 404:\n%s", body) |
| 1447 | } |
| 1448 | } |
| 1449 | |
| 1450 | // The Settings page was the last first-party surface still telling the |
| 1451 | // pre-#299 story: it read THIS box's registry copy and printed a DNS word, |
| 1452 | // while `carlos domains list` read the fleet-wide sweep and could be saying |
| 1453 | // something else about the same hostname at the same moment. Both must now |
| 1454 | // come from domainState over the same inputs. |
| 1455 | func TestSettingsDomainsCarryTheSameStateTheAPIDoorDerives(t *testing.T) { |
| 1456 | fx := newConsoleFixture(t) |
| 1457 | ctx := context.Background() |
| 1458 | now := time.Now() |
| 1459 | acct, err := fx.st.AccountBySqid(fx.sqid) |
| 1460 | if err != nil || acct == nil { |
| 1461 | t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| 1462 | } |
| 1463 | if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| 1464 | Host: "www.customer.test", Account: fx.sqid, App: "kass", |
| 1465 | }); err != nil { |
| 1466 | t.Fatal(err) |
| 1467 | } |
| 1468 | if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| 1469 | GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", |
| 1470 | Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| 1471 | "www.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()}, |
| 1472 | }}, |
| 1473 | }); err != nil { |
| 1474 | t.Fatal(err) |
| 1475 | } |
| 1476 | got := fx.srv.settingsDomains(ctx, acct, "kass", nil) |
| 1477 | if len(got) != 1 { |
| 1478 | t.Fatalf("rows = %+v", got) |
| 1479 | } |
| 1480 | wantState, wantReason, wantDetail := domainState( |
| 1481 | status.AliasReading{Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()}, |
| 1482 | true, nil, now) |
| 1483 | if got[0].State != wantState || got[0].Reason != wantReason || got[0].Detail != wantDetail { |
| 1484 | t.Errorf("row = state %q reason %q detail %q; want %q / %q / %q — the page and the CLI must derive one verdict", |
| 1485 | got[0].State, got[0].Reason, got[0].Detail, wantState, wantReason, wantDetail) |
| 1486 | } |
| 1487 | if got[0].Status() != wantState { |
| 1488 | t.Errorf("Status() = %q, want the derived state %q", got[0].Status(), wantState) |
| 1489 | } |
| 1490 | } |
| 1491 | |
| 1492 | // A failing certificate reaches the page, through the same warm-outcome |
| 1493 | // record the API doors read (#299 §3e). This is the surface a member is most |
| 1494 | // likely to be looking at when it happens. |
| 1495 | func TestSettingsDomainsShowAFailedCertificate(t *testing.T) { |
| 1496 | fx := newConsoleFixture(t) |
| 1497 | ctx := context.Background() |
| 1498 | now := time.Now() |
| 1499 | acct, err := fx.st.AccountBySqid(fx.sqid) |
| 1500 | if err != nil || acct == nil { |
| 1501 | t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| 1502 | } |
| 1503 | if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| 1504 | Host: "broken.customer.test", Account: fx.sqid, App: "kass", |
| 1505 | }); err != nil { |
| 1506 | t.Fatal(err) |
| 1507 | } |
| 1508 | if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| 1509 | GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", |
| 1510 | Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| 1511 | "broken.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "pointing", DNSCheckedAt: now.Unix()}, |
| 1512 | }}, |
| 1513 | }); err != nil { |
| 1514 | t.Fatal(err) |
| 1515 | } |
| 1516 | body, err := certwarm.Record{Host: "broken.customer.test", Attempts: domainFailedAttempts, |
| 1517 | Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()}.Encode(now) |
| 1518 | if err != nil { |
| 1519 | t.Fatal(err) |
| 1520 | } |
| 1521 | if _, err := fx.rel.Put(ctx, certwarm.Key("broken.customer.test"), body, ""); err != nil { |
| 1522 | t.Fatal(err) |
| 1523 | } |
| 1524 | got := fx.srv.settingsDomains(ctx, acct, "kass", nil) |
| 1525 | if len(got) != 1 || got[0].State != StateFailed { |
| 1526 | t.Fatalf("rows = %+v, want one row in state %q", got, StateFailed) |
| 1527 | } |
| 1528 | // And it EXPLAINS. A one-word "failed" with no sentence under it is the |
| 1529 | // worst version of this row. |
| 1530 | if got[0].Explain() == "" { |
| 1531 | t.Error("a failed certificate row carries no explanation") |
| 1532 | } |
| 1533 | } |
| 1534 | |
| 1535 | // Explain is what keeps the page readable: a working domain gets no |
| 1536 | // paragraph, because a sentence repeated under every healthy row trains |
| 1537 | // people to skip the one under the broken row. |
| 1538 | func TestDomainViewExplainStaysQuietWhenThereIsNothingToSay(t *testing.T) { |
| 1539 | for _, tc := range []struct { |
| 1540 | name string |
| 1541 | d DomainView |
| 1542 | want string |
| 1543 | }{ |
| 1544 | {"live says nothing", DomainView{State: StateLive, Detail: "served over HTTPS"}, ""}, |
| 1545 | {"a problem says its detail", DomainView{State: StateMisdirected, Detail: "points elsewhere"}, "points elsewhere"}, |
| 1546 | {"a problem with records says both", |
| 1547 | DomainView{State: StatePendingDNS, Detail: "no record yet", Guidance: "Point an A record."}, |
| 1548 | "no record yet Point an A record."}, |
| 1549 | // A row the shared view builder produced, with no fleet fold behind |
| 1550 | // it, must render exactly as it always has. |
| 1551 | {"no state falls back to guidance", DomainView{Guidance: "Point an A record."}, "Point an A record."}, |
| 1552 | {"no state and no guidance", DomainView{}, ""}, |
| 1553 | } { |
| 1554 | if got := tc.d.Explain(); got != tc.want { |
| 1555 | t.Errorf("%s: Explain() = %q, want %q", tc.name, got, tc.want) |
| 1556 | } |
| 1557 | } |
| 1558 | } |
| 1559 | |