package console // The app Settings tab's write half (appsettings.go): rename, delete, and the // account-move repair, plus the store verb rename is built on. // // The theme running through all of it is that this console may only change its // OWN record. Every test here therefore checks two things about a refusal: that // the person is told, and that nothing outside this database moved — because // the whole reason renaming is refused after a ship, and deletion is refused on // a production app, is that the other stores would not follow. import ( "context" "errors" "github.com/carlosframework/platform/internal/certwarm" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/status" "net/http" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // ---- Store.RenameAppIn ------------------------------------------------------ // renameStore is an account with one app in it — the smallest world a rename // means anything in. func renameStore(t *testing.T, app string) (*Store, int64) { t.Helper() s := openTestStore(t) id := testAccountID() if err := s.CreateAccount(id, "rn"+app, "Renameco", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if created, err := s.CreateAppIn(id, app, "paul@keymail.dev", time.Now()); err != nil || !created { t.Fatalf("CreateAppIn = %v %v", created, err) } return s, id } // The happy path, and the half that is easy to forget: the app's audit history // follows it. Its story belongs to the app, not to a name that no longer // resolves — a rename that stranded the trail would leave the Activity tab of // the renamed app blank and the events unreachable under any name. // // RED as written (2026-08-07): RenameAppIn's UPDATE is guarded by // `deleted_at IS NULL`, and apps.deleted_at is `INTEGER NOT NULL DEFAULT 0` — // so the guard matches no row, ever, and every rename returns "this account // does not claim %q". The guard the rest of the file uses for "live" is // `deleted_at = 0`. func TestRenameAppInStoreRenamesRowAndAudit(t *testing.T) { s, id := renameStore(t, "alpha") if err := s.RecordEvent(id, "alpha", "paul@keymail.dev", "create", "created app alpha", time.Now()); err != nil { t.Fatal(err) } if err := s.RenameAppIn(id, "alpha", "beta"); err != nil { t.Fatalf("RenameAppIn(alpha -> beta) = %v, want the rename to land", err) } if has, err := s.HasAppIn(id, "beta"); err != nil || !has { t.Fatalf("HasAppIn(beta) = %v %v, want the row under its new name", has, err) } if has, err := s.HasAppIn(id, "alpha"); err != nil || has { t.Fatalf("HasAppIn(alpha) = %v %v, want the old name gone", has, err) } moved, err := s.Events(id, "beta", 10) if err != nil { t.Fatal(err) } if len(moved) != 1 || moved[0].Detail != "created app alpha" { t.Fatalf("Events(beta) = %+v, want the app's history to have followed it", moved) } left, err := s.Events(id, "alpha", 10) if err != nil { t.Fatal(err) } if len(left) != 0 { t.Fatalf("Events(alpha) = %+v, want nothing left under the old name", left) } } // The name must be free in this account — and a TRASHED row still owns its // name until it is purged, exactly as CreateAppIn treats it. Renaming onto one // would resurrect a collision the moment the trashed app was restored. func TestRenameAppInRefusesTakenNames(t *testing.T) { s, id := renameStore(t, "alpha") if _, err := s.CreateAppIn(id, "live", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if _, err := s.CreateAppIn(id, "binned", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if err := s.TrashAppRow(id, "binned", time.Now()); err != nil { t.Fatal(err) } for _, to := range []string{"live", "binned"} { err := s.RenameAppIn(id, "alpha", to) if err == nil { t.Fatalf("RenameAppIn(alpha -> %s) succeeded onto a claimed name", to) } if !strings.Contains(err.Error(), "already has an app") { t.Fatalf("RenameAppIn(alpha -> %s) = %v, want the taken-name refusal", to, err) } // And the refusal is total: nothing moved. if has, err := s.HasAppIn(id, "alpha"); err != nil || !has { t.Fatalf("HasAppIn(alpha) = %v %v after a refused rename, want it untouched", has, err) } } } // Legacy owners-table grants are keyed by BARE app name across all accounts, so // rewriting one could hand an account's grant to another, and renaming ONTO one // would let the new name inherit a grant nobody issued it. Both directions are // refused. func TestRenameAppInRefusesLegacyGrants(t *testing.T) { s, id := renameStore(t, "alpha") if err := s.Grant("paul@keymail.dev", "alpha"); err != nil { t.Fatal(err) } if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil || !strings.Contains(err.Error(), "legacy owner grant") { t.Fatalf("renaming FROM a granted name = %v, want the legacy-grant refusal", err) } if err := s.Revoke("paul@keymail.dev", "alpha"); err != nil { t.Fatal(err) } if err := s.Grant("paul@keymail.dev", "gamma"); err != nil { t.Fatal(err) } if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil || !strings.Contains(err.Error(), "legacy owner grant") { t.Fatalf("renaming ONTO a granted name = %v, want the legacy-grant refusal", err) } if has, err := s.HasAppIn(id, "alpha"); err != nil || !has { t.Fatalf("HasAppIn(alpha) = %v %v after the refusals, want it untouched", has, err) } } // Renaming something this account does not claim is an error, not a silent // no-op — the handler turns it into "could not rename", and a quiet success // would tell the owner an app moved when nothing did. func TestRenameAppInRefusesMissingSource(t *testing.T) { s, id := renameStore(t, "alpha") err := s.RenameAppIn(id, "ghost", "gamma") if err == nil { t.Fatal("renaming an app this account does not claim reported success") } if !strings.Contains(err.Error(), "does not claim") { t.Fatalf("RenameAppIn(ghost -> gamma) = %v, want the unclaimed-source refusal", err) } if has, err := s.HasAppIn(id, "gamma"); err != nil || has { t.Fatalf("HasAppIn(gamma) = %v %v, want no row conjured by the failed rename", has, err) } } // ---- handleAppRename -------------------------------------------------------- // freshApp claims an app name in the fixture account with nothing behind it: // no bucket keys, no routes. That is the ONLY state a rename is offered in, so // it is the state most of these tests start from. func freshApp(t *testing.T, fx *consoleFixture, app string) { t.Helper() if created, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil || !created { t.Fatalf("CreateAppIn(%s) = %v %v", app, created, err) } } // shipSomething puts one object under the app's bucket prefix. Which object is // beside the point: appDeployEmpty refuses on ANY key, because one key is // enough to prove the name is baked into a storage layer this handler cannot // rewrite. func shipSomething(t *testing.T, fx *consoleFixture, app string) { t.Helper() key := release.AppPrefix(fx.sqid, app) + "releases/v1/manifest.json" if _, err := fx.rel.Put(context.Background(), key, []byte(`{"schema":1}`), ""); err != nil { t.Fatal(err) } } // The card, then the rename: a fresh app's name is still only a record here, // so the whole ceremony is confirm-and-go, with no passkey. // // RED as written (2026-08-07) for the same reason as // TestRenameAppInStoreRenamesRowAndAudit: the store verb underneath cannot // match a live row, so the confirmed POST flashes "Could not rename" and // lands back on the OLD settings URL. The card half of this test is green. func TestRenameHandlerConfirmsThenRenames(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "sprout") c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) form := url.Values{"csrf": {csrf}, "to": {"sapling"}} status, loc, body := postForm(t, c, fx.u("/apps/sprout/rename"), form) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("renaming a never-shipped app demanded a passkey (-> %q)", loc) } card := confirmCard(t, status, body, "Rename sprout to sapling?", csrf) if !strings.Contains(card, ``) { t.Fatalf("the card does not carry the new name:\n%s", card) } if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has { t.Fatalf("HasAppIn(sprout) = %v %v after the card, want it unrenamed", has, err) } form.Set("confirmed", "1") status, loc, body = postForm(t, c, fx.u("/apps/sprout/rename"), form) if status != http.StatusSeeOther || loc != fx.path("/apps/sapling/settings") { t.Fatalf("confirmed rename = %d -> %q, want 303 to the NEW settings URL:\n%s", status, loc, body) } if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || !has { t.Fatalf("HasAppIn(sapling) = %v %v, want the app under its new name", has, err) } if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || has { t.Fatalf("HasAppIn(sprout) = %v %v, want the old name gone", has, err) } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "Renamed sprout to sapling.") { t.Fatalf("no rename flash:\n%s", page) } auditedBy(t, fx, "sapling", "rename", "paul@keymail.dev", "renamed sprout to sapling") } // One key under the app's prefix is enough. The refusal is a flash, not a card: // the handler decided before asking, so there is nothing to confirm. func TestRenameHandlerRefusesShippedApp(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "shipped") shipSomething(t, fx, "shipped") c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postForm(t, c, fx.u("/apps/shipped/rename"), url.Values{"csrf": {csrf}, "to": {"renamed"}, "confirmed": {"1"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/shipped/settings") { t.Fatalf("rename of a shipped app = %d -> %q, want the flash redirect:\n%s", status, loc, body) } notACard(t, body) if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "This app has shipped") { t.Fatalf("no shipped refusal flash:\n%s", page) } if has, err := fx.st.HasAppIn(OpsAccountID, "shipped"); err != nil || !has { t.Fatalf("HasAppIn(shipped) = %v %v, want the app untouched", has, err) } if has, err := fx.st.HasAppIn(OpsAccountID, "renamed"); err != nil || has { t.Fatalf("HasAppIn(renamed) = %v %v, want no new row", has, err) } } // A route wearing the name is the other wider store: the name is the address. // Checked at ACTION time, not from the page's advice — a route added since the // page rendered must still stop the rename. func TestRenameHandlerRefusesRoutedApp(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "wired") c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // Added AFTER the app was claimed and with nothing in the bucket, so the // deploy-empty check passes and the ROUTE check is what refuses. fx.addRoute(t, registry.Route{Host: "wired.carlosapp.dev", Kind: "instance", Status: "ready", App: "wired", Channel: "stable", Addr: "127.0.0.1:9031"}) status, loc, body := postForm(t, c, fx.u("/apps/wired/rename"), url.Values{"csrf": {csrf}, "to": {"unwired"}, "confirmed": {"1"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/wired/settings") { t.Fatalf("rename of a routed app = %d -> %q:\n%s", status, loc, body) } notACard(t, body) if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "A host still routes this name") { t.Fatalf("no routed refusal flash:\n%s", page) } if has, err := fx.st.HasAppIn(OpsAccountID, "unwired"); err != nil || has { t.Fatalf("HasAppIn(unwired) = %v %v, want no rename", has, err) } } // Renaming is the owner's. A member meets the byte-identical 404 — before any // card could concede the action exists. func TestRenameHandlerIsOwnerOnly(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "sprout") if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } fx.setUser("sam@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-sam-rename") csrf := sessionToken(t, fx, c) status, _, memberBody := postForm(t, c, fx.u("/apps/sprout/rename"), url.Values{"csrf": {csrf}, "to": {"sapling"}}) if status != http.StatusNotFound { t.Fatalf("member renaming = %d, want 404", status) } notACard(t, memberBody) strangerBody := strangerRefusal(t, fx, "code-zoe-rename", fx.u("/apps/sprout/rename"), url.Values{"to": {"sapling"}}) if memberBody != strangerBody { t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) } if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has { t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err) } } // CSRF is checked before ownership and before anything is read — a cross-site // POST is refused with 403 and never reaches the store. func TestRenameHandlerNeedsCSRF(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "sprout") c := signInPaul(t, fx) status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"), url.Values{"csrf": {"not-the-token"}, "to": {"sapling"}, "confirmed": {"1"}}) if status != http.StatusForbidden { t.Fatalf("rename with a bad csrf = %d, want 403:\n%s", status, body) } if !strings.Contains(body, "Cross-site request blocked.") { t.Fatalf("403 body does not name the reason:\n%s", body) } if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has { t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err) } } // ---- handleAppDelete -------------------------------------------------------- // appTombstone reads the bucket's deletion marker — the thing that actually // makes a host stop serving. The console row alone is not a deletion. func appTombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone { t.Helper() tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, app) if err != nil { t.Fatal(err) } return tomb } // Typing the name is a "did you mean this app?" control, checked BEFORE the // passkey so a slip does not cost a passkey touch — and, more to the point, // does not delete anything. func TestAppDeleteRequiresTheExactName(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postForm(t, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kas"}}) if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body) } if loc != fx.path("/apps/kass/settings/delete") { t.Fatalf("mistyped delete redirected to %q, want the Settings tab", loc) } // The apostrophe in "app's" is HTML-escaped on the way out, so the // assertion takes the unambiguous tail of the sentence. if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "name exactly to delete it.") { t.Fatalf("no mistyped-name flash:\n%s", page) } if tomb := appTombstone(t, fx, "kass"); tomb != nil { t.Fatalf("a mistyped delete wrote a trash marker: %+v", tomb) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasAppIn = %v %v, want the row live", has, err) } var stashed int fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) if stashed != 0 { t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway") } } // The whole ceremony: the name, then the passkey, then the marker and the row, // in that order, landing on the account home. func TestAppDeleteTrashesBehindThePasskey(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) form := url.Values{"csrf": {csrf}, "confirm": {"kass"}} // Without the passkey, nothing moves. (postForm, not postGated: the // un-stepped-up post is what proves the gate is there at all.) status, loc, _ := postForm(t, c, fx.u("/apps/kass/delete"), form) if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc) } if tomb := appTombstone(t, fx, "kass"); tomb != nil { t.Fatalf("a trash marker was written before the passkey confirmation: %+v", tomb) } status, loc, body := completeStepUp(t, fx, c, loc) if status != http.StatusSeeOther || loc != fx.path("/") { t.Fatalf("resumed delete = %d -> %q, want 303 to the account home:\n%s", status, loc, body) } tomb := appTombstone(t, fx, "kass") if tomb == nil { t.Fatal("no trash marker in the bucket — hosts would go on serving the app") } if tomb.Actor != "paul@keymail.dev" { t.Fatalf("marker actor = %q, want the owner who deleted it", tomb.Actor) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has { t.Fatalf("HasAppIn = %v %v, want the live row gone", has, err) } if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasTrashedAppIn = %v %v, want the row in the trash", has, err) } auditedBy(t, fx, "kass", "trash", "paul@keymail.dev", "moved kass to the trash") } // Production outranks deletion, all the way through the ceremony: the flag is // read after the passkey, and refuses even a fully confirmed owner. func TestAppDeleteRefusesAProductionApp(t *testing.T) { fx := newConsoleFixture(t) if err := release.SetProduction(context.Background(), fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { t.Fatal(err) } c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kass"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/delete") { t.Fatalf("delete of a production app = %d -> %q:\n%s", status, loc, body) } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is marked as production, so deleting is refused") { t.Fatalf("no production refusal flash:\n%s", page) } if tomb := appTombstone(t, fx, "kass"); tomb != nil { t.Fatalf("a production app got a trash marker anyway: %+v", tomb) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasAppIn = %v %v, want the row live", has, err) } } // Deleting is the owner's, and the refusal is the byte-identical 404 — a // member must not learn that the door exists. func TestAppDeleteIsOwnerOnly(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } fx.setUser("sam@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-sam-appdelete") csrf := sessionToken(t, fx, c) status, _, memberBody := postForm(t, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kass"}}) if status != http.StatusNotFound { t.Fatalf("member deleting an app = %d, want 404", status) } strangerBody := strangerRefusal(t, fx, "code-zoe-appdelete", fx.u("/apps/kass/delete"), url.Values{"confirm": {"kass"}}) if memberBody != strangerBody { t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) } if tomb := appTombstone(t, fx, "kass"); tomb != nil { t.Fatalf("a member's refused delete wrote a marker: %+v", tomb) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasAppIn = %v %v, want the row live", has, err) } } // ---- handleAppMoveAccount --------------------------------------------------- // stampKassRoutes re-writes the fixture's kass routes with a stamped account, // which is what `carlos accounts migrate` leaves behind: the routes name the // new account, the console record still names the old one. That disagreement // IS the state this handler repairs — and the target comes from the routes, // never the form. func stampKassRoutes(t *testing.T, fx *consoleFixture, acct string) { t.Helper() fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready", Version: "v1", App: "kass", Channel: "stable", Account: acct, Addr: "127.0.0.1:9001"}) fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready", Version: "v1", App: "kass", Account: acct, Hibernate: true}) } // The repair: card, passkey, and the record lands in the account the routes // already deploy from — with the arrival recorded on BOTH accounts, since // neither activity trail alone tells the whole story afterwards. func TestAppMoveConfirmsThenMovesTheRecord(t *testing.T) { fx := newConsoleFixture(t) const destID = 4242 if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } stampKassRoutes(t, fx, "cdef") c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) form := url.Values{"csrf": {csrf}} status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), form) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("the owner met the passkey before the card (-> %q)", loc) } card := confirmCard(t, status, body, "Move kass to Second?", csrf) if !strings.Contains(card, "Move to Second") { t.Fatalf("the card's button does not name the destination:\n%s", card) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasAppIn(ops) = %v %v after the card, want the record unmoved", has, err) } form.Set("confirmed", "1") status, loc, _ = postForm(t, c, fx.u("/apps/kass/move"), form) if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("confirmed move = %d -> %q, want a redirect into Keymail", status, loc) } if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has { t.Fatalf("HasAppIn(dest) = %v %v before the passkey, want nothing moved", has, err) } status, loc, body = completeStepUp(t, fx, c, loc) if status != http.StatusSeeOther || loc != "/a/cdef/apps/kass" { t.Fatalf("resumed move = %d -> %q, want 303 to the app under its new account:\n%s", status, loc, body) } if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || !has { t.Fatalf("HasAppIn(dest) = %v %v, want the record in the destination account", has, err) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has { t.Fatalf("HasAppIn(ops) = %v %v, want the record gone from the source", has, err) } // Both trails: the source records the departure, the destination the arrival. auditedBy(t, fx, "kass", "move", "paul@keymail.dev", "moved kass to account Second (cdef)") arrived, err := fx.st.Events(destID, "kass", 10) if err != nil { t.Fatal(err) } var found bool for _, e := range arrived { if e.Action == "move" && strings.Contains(e.Detail, "arrived from account") { found = true } } if !found { t.Fatalf("destination events = %+v, want the arrival recorded there too", arrived) } } // Moving the record into an account the viewer does not belong to would strand // the app for them. The refusal must not disclose that account as a move target. func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) { fx := newConsoleFixture(t) const destID = 4343 if err := fx.st.CreateAccount(destID, "cghi", "Third", "kim@keymail.dev", time.Now()); err != nil { t.Fatal(err) } stampKassRoutes(t, fx, "cghi") c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, _, body := postGated(t, fx, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page") if status != http.StatusNotFound || status != ghostStatus || body != ghostBody { t.Fatalf("move into a stranger account = %d %q; unrouted = %d %q", status, body, ghostStatus, ghostBody) } notACard(t, body) if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err) } if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has { t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved", has, err) } } // With no disagreement there is nothing to repair — and no card, because the // handler has already decided. func TestAppMoveRefusesWhenRoutesAgree(t *testing.T) { fx := newConsoleFixture(t) // The fixture's routes carry no stamped account, which reads as the ops // account — the very account this page belongs to. c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { t.Fatalf("move with agreeing routes = %d -> %q:\n%s", status, loc, body) } notACard(t, body) if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "there is nothing to move") { t.Fatalf("no nothing-to-move flash:\n%s", page) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasAppIn = %v %v, want the record untouched", has, err) } } // A PARTIAL mismatch is not a move. The Settings card is only offered when // every non-platform route disagrees (AllMismatched), but a direct POST — or a // tab left open across a re-stamp — reaches the handler regardless, and the // target it derives is only the FIRST route's account. Here that first route // (docs.* sorts before kass.*, and List orders by host) is the ONLY one // stamped elsewhere: acting on it would move the whole record to where a // minority of the app's hosts deploy from, converting a partial disagreement // into a total one the other way. The routes are the repair, so the handler // says so — before the card, and before anything is written. func TestAppMoveRefusesAPartialMismatch(t *testing.T) { fx := newConsoleFixture(t) const destID = 4444 // A destination that would otherwise pass every later check — known to the // console, and one paul belongs to — so the refusal can only be the // disagreement itself. if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if err := fx.st.AddMember(destID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil { t.Fatal(err) } // One of kass's two routes re-stamped, and it is the one that sorts first. fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready", Version: "v1", App: "kass", Account: "cdef", Hibernate: true}) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // Unconfirmed: no card, because a card here would claim the app's hosts // already read from Second when only one of them does. status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { t.Fatalf("partial-mismatch move = %d -> %q:\n%s", status, loc, body) } notACard(t, body) if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "routes disagree among themselves") { t.Fatalf("no routes-disagree refusal flash:\n%s", page) } // Confirmed, straight at the door: same refusal, and nothing moved. status, loc, body = postGated(t, fx, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}, "confirmed": {"1"}, "to": {"cdef"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") { t.Fatalf("confirmed partial-mismatch move = %d -> %q:\n%s", status, loc, body) } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "routes disagree among themselves") { t.Fatalf("no routes-disagree refusal flash on the confirmed POST:\n%s", page) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err) } if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has { t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved to the minority account", has, err) } } // ---- the Settings page ------------------------------------------------------ // The rename form is offered while the name is still only a record here, and // replaced by the reason the moment the bucket knows it. func TestSettingsPageRenameFormFollowsTheBucket(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "sprout") c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/sprout/settings")) if status != http.StatusOK { t.Fatalf("settings page = %d:\n%s", status, body) } if !strings.Contains(body, `id="rename-to"`) || !strings.Contains(body, `action="/a/bab/apps/sprout/rename"`) { t.Fatalf("a never-shipped app is offered no rename form:\n%s", body) } shipSomething(t, fx, "sprout") _, _, body = get(t, c, fx.u("/apps/sprout/settings")) if strings.Contains(body, `id="rename-to"`) { t.Fatalf("the rename form survived the app's first ship:\n%s", body) } if !strings.Contains(body, "Renaming: This app has shipped") { t.Fatalf("the page does not say why renaming is off:\n%s", body) } } // Everything on this page that changes the app's identity is the owner's. A // member sees the identity and the domains, and neither form. func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "sprout") // The owner's own view first, so the member-side absences below cannot // pass vacuously: both forms ARE eligible to render in this exact state. owner := signInPaul(t, fx) _, _, obody := get(t, owner, fx.u("/apps/sprout/settings")) _, _, odelete := get(t, owner, fx.u("/apps/sprout/settings/delete")) if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(odelete, `id="delete-confirm"`) { t.Fatalf("the owner is not offered both forms — the member checks below would be vacuous:\n%s", obody) } if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } fx.setUser("sam@keymail.dev") cm := newBrowser(t) signUp(t, fx, cm, "code-sam-settings") status, _, body := get(t, cm, fx.u("/apps/sprout/settings")) if status != http.StatusOK { t.Fatalf("member settings page = %d, want 200:\n%s", status, body) } _, _, domainsBody := get(t, cm, fx.u("/apps/sprout/settings/domains")) if !strings.Contains(domainsBody, "Domains & TLS") { t.Fatalf("a member cannot see the page at all:\n%s", body) } if strings.Contains(body, `id="rename-to"`) || strings.Contains(body, "/rename\"") { t.Errorf("a member is offered the rename form:\n%s", body) } _, _, deleteBody := get(t, cm, fx.u("/apps/sprout/settings/delete")) if strings.Contains(deleteBody, `id="delete-confirm"`) || strings.Contains(deleteBody, `action="/a/bab/apps/sprout/delete"`) { t.Errorf("a member is offered the delete form:\n%s", deleteBody) } } // ---- the store card and its destroy door ------------------------------------ // // Destroying a store is the only console action that destroys a customer's // OBJECTS outside the app trash, and there is no trash for a bucket. So the // ceremony is the purge door's, with one difference that matters: what a person // types is the BUCKET name, because the bucket is the thing that stops // existing — an app name typed here would confirm the wrong noun. // pendingKey puts the record into the half-finished rotation state: two keys // open the bucket until the member finishes it, and the page must nag. func pendingKey(t *testing.T, fx *consoleFixture, app, prev string) { t.Helper() ctx := context.Background() rec, etag, err := objstore.Get(ctx, fx.rel, fx.sqid, app) if err != nil || rec == nil || rec.Provisioned == nil { t.Fatalf("read record: %+v %v", rec, err) } rec.Provisioned.PrevKeyID = prev if _, err := objstore.Put(ctx, fx.rel, fx.sqid, app, rec, etag); err != nil { t.Fatal(err) } } // stashedActions counts parked step-up actions — the evidence that a passkey // was demanded. A refusal that gets this far has already cost a gesture. func stashedActions(t *testing.T, fx *consoleFixture) int { t.Helper() var n int if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&n); err != nil { t.Fatal(err) } return n } // The typed word is the bucket, checked BEFORE the passkey: a slip must cost a // retry, never a gesture, and must destroy nothing. The app's own name is the // most likely wrong answer, so it is the one under test. func TestStoreDestroyRefusesAnythingButTheBucketName(t *testing.T) { fx := newConsoleFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) for _, typed := range []string{"kass", "", "carlos-bab-kas"} { status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"), url.Values{"csrf": {csrf}, "confirm": {typed}}) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("confirm %q demanded a passkey before it was even checked (-> %q)", typed, loc) } if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { t.Fatalf("confirm %q = %d -> %q, want the settings flash:\n%s", typed, status, loc, body) } if n := stashedActions(t, fx); n != 0 { t.Fatalf("confirm %q parked %d pending actions — the passkey was asked for anyway", typed, n) } estateStanding(t, fx, fake, "kass", bucket, "a mistyped destroy") if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { t.Fatalf("a mistyped destroy touched the record: %+v", rec) } } if _, _, page := get(t, c, fx.u("/apps/kass/settings/storage")); !strings.Contains(page, bucket) { t.Fatalf("the refusal does not tell the owner what to type:\n%s", page) } } // The whole ceremony: the bucket typed, the passkey, and then everything — // objects, bucket, principal, key and the record — is gone. The serial moves // because the delivered variables were unset: every box serving this app is // running with credentials that no longer work, and must converge. func TestStoreDestroyDestroysTheEstateAndAnnounces(t *testing.T) { fx := newConsoleFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) before := serial(t, fx) // Without the passkey, nothing moves. status, loc, _ := postForm(t, c, fx.u("/apps/kass/store/destroy"), url.Values{"csrf": {csrf}, "confirm": {bucket}}) if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("un-stepped-up destroy = %d -> %q, want a redirect into Keymail", status, loc) } estateStanding(t, fx, fake, "kass", bucket, "a destroy awaiting its passkey") status, loc, body := completeStepUp(t, fx, c, loc) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { t.Fatalf("resumed destroy = %d -> %q:\n%s", status, loc, body) } estateGone(t, fake, "a completed destroy") if rec := storedRecord(t, fx, "kass"); rec != nil { t.Fatalf("the store record survived its own destruction: %+v", rec) } if got := serial(t, fx); got <= before { t.Fatalf("serial after a destroy = %d, want a bump from %d — the boxes must converge on the unset variables", got, before) } auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", bucket) if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, bucket) { t.Fatalf("the destroy flash does not name what was destroyed:\n%s", page) } } // TestStoreDestroyLeavesTheEmailEstateAlone: R3, review follow-up. A // previous fix wave wired mail.Teardown into THIS door — but this door's // confirm word is the bucket name, its button says "Destroy store", and its // success flash (asserted above) names only the object store. Nothing here // ever told the owner that confirming would also destroy the SES identity, // the IAM principal, delivered credentials, published DNS and — globally, // irreversibly — the domain claim. Consent for the store is not consent for // email; ownership.go's handlePurgeApp (the whole-app delete) is the door // that destroys both, and says so. // // The proof is the claim's own semantics (mirroring // TestPurgeReleasesTheDomainClaim in deletion_test.go), not an internal peek // at bucket keys: a SECOND, unrelated account trying to claim the SAME // domain is refused with ErrDomainClaimed both before and after the store // destroy — if this door released it, the second account's EnsureDomain // would start succeeding. func TestStoreDestroyLeavesTheEmailEstateAlone(t *testing.T) { fx := newConsoleFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) mfake := mail.NewMemProvisioner("1") writer := dnsrec.NewMemWriter() fx.srv.cfg.MailProvisioner = mfake fx.srv.cfg.DNSWriter = writer dep := mail.Deployment{Region: "eu-west-1"} // no PlatformSuffix, matching // TestPurgeReleasesTheDomainClaim: keeps this about the claim gate // alone, not the separate platform-name-ownership gate. ctx := context.Background() now := time.Now() domain := "store-destroy-claim.example.com" // Deliberately no mail.Declare/Enable — kass has a domain record (and // the claim it holds) but no Provisioned credential, exactly the shape // `carlos email domains add` alone leaves. If this door's old // mail.Teardown wiring were still here, it would find and release this // claim too — mail.Teardown traces the domain-referrer set, not just a // delivered credential. if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, fx.sqid, "kass", domain, now); err != nil { t.Fatalf("EnsureDomain for kass: %v", err) } if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) { t.Fatalf("a second account's EnsureDomain before the destroy = %v, want ErrDomainClaimed", err) } c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"), url.Values{"csrf": {csrf}, "confirm": {bucket}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { t.Fatalf("store destroy = %d -> %q:\n%s", status, loc, body) } estateGone(t, fake, "a store destroy that must leave email alone") if rec := storedRecord(t, fx, "kass"); rec != nil { t.Fatalf("the store record survived its own destruction: %+v", rec) } if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) { t.Fatalf("the domain claim was released by a store destroy: err = %v, want it still ErrDomainClaimed", err) } } // A declaration is a request nobody has acted on: no bucket, no key, nothing to // spend backend authority on. So a console with no backend at all may still // withdraw it — and the word to type is the APP's name, because that is the // only noun this record has. func TestStoreDestroyOfADeclarationNeedsNoBackend(t *testing.T) { fx := newConsoleFixture(t) putRecord(t, fx, "kass", &objstore.Record{ Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: time.Now(), By: "paul@keymail.dev"}, }) fx.srv.cfg.StoreProvisioner = nil c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"), url.Values{"csrf": {csrf}, "confirm": {"kass"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { t.Fatalf("destroy of a declaration = %d -> %q:\n%s", status, loc, body) } if rec := storedRecord(t, fx, "kass"); rec != nil { t.Fatalf("the declaration survived: %+v", rec) } auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "declaration") } // A provisioned store on a backend-less console is the other half of that rule: // the bucket and the key are real and this console has no authority over them, // so it refuses and names the variable that would give it some — before the // passkey, since no gesture could change the answer. func TestStoreDestroyRefusesAnEstateWithNoBackend(t *testing.T) { fx := newConsoleFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) fx.srv.cfg.StoreProvisioner = nil c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"), url.Values{"csrf": {csrf}, "confirm": {bucket}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { t.Fatalf("backend-less destroy = %d -> %q:\n%s", status, loc, body) } if n := stashedActions(t, fx); n != 0 { t.Fatalf("a refusal nothing could change still cost a passkey (%d parked actions)", n) } estateStanding(t, fx, fake, "kass", bucket, "a backend-less destroy") if rec := storedRecord(t, fx, "kass"); rec == nil { t.Fatal("the record was deleted with the estate still standing") } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "CARLOS_STORE_BACKEND") { t.Fatalf("the refusal does not name the backend variable:\n%s", page) } } // A backend that refuses mid-walk must leave the record naming the estate: the // record is the ONLY thing that names a bucket and a principal, so a door that // deleted it on a failed teardown would leave a paid, credentialed estate // nobody can find. The person is told it is still there, which is also the // instruction — re-entering the door resumes. func TestStoreDestroyFailureLeavesTheRecordNamingTheEstate(t *testing.T) { fx := newConsoleFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) fake.FailNext["EmptyBucket"] = errors.New("backend unreachable") c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"), url.Values{"csrf": {csrf}, "confirm": {bucket}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") { t.Fatalf("failed destroy = %d -> %q:\n%s", status, loc, body) } estateStanding(t, fx, fake, "kass", bucket, "a failed destroy") if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { t.Fatalf("a failed destroy deleted the record naming the estate: %+v", rec) } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "still there") { t.Fatalf("the failure does not say the store survived:\n%s", page) } } // Destroying is the account owner's. A member meets the same 404 a stranger // does — before any passkey redirect could concede the app, or the store, // exists. func TestStoreDestroyIsOwnerOnly(t *testing.T) { fx := newConsoleFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } fx.setUser("sam@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-sam-store-destroy") csrf := sessionToken(t, fx, c) status, _, memberBody := postForm(t, c, fx.u("/apps/kass/store/destroy"), url.Values{"csrf": {csrf}, "confirm": {bucket}}) if status != http.StatusNotFound { t.Fatalf("member destroying a store = %d, want 404", status) } strangerBody := strangerRefusal(t, fx, "code-zoe-store-destroy", fx.u("/apps/kass/store/destroy"), url.Values{"confirm": {bucket}}) if memberBody != strangerBody { t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) } estateStanding(t, fx, fake, "kass", bucket, "a member's refused destroy") } // The card is the member's only sight of a store they cannot reach with box // commands: what prefix it delivers under, which bucket, which key and how old // that key is — labelled as the KEY's age, because a grant repair moves that // timestamp and calling it "provisioned" would be a lie the page tells forever. func TestSettingsPageShowsTheStoreCard(t *testing.T) { fx := newConsoleFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) pendingKey(t, fx, "kass", "MEMKEY-OLD") keyID := storedRecord(t, fx, "kass").Provisioned.KeyID c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/settings/storage")) if status != http.StatusOK { t.Fatalf("settings page = %d:\n%s", status, body) } for _, want := range []string{ objstore.DefaultEnvPrefix, // the env set the app reads bucket, // the bucket its data is in keyID, // the key that opens it "last credential change", // what the age actually means "carlos store rotate --finish --app kass", // the half-done rotation nag `id="store-destroy-confirm"`, // the destroy form } { if !strings.Contains(body, want) { t.Fatalf("the store card is missing %q:\n%s", want, body) } } if strings.Contains(body, "Provisioned") { t.Fatalf("the card labels the key timestamp as a provisioning date:\n%s", body) } // An app nobody has declared a store for gets the one line that tells them // how to ask for one — and no destroy form for a store that isn't there. freshApp(t, fx, "sprout") _, _, body = get(t, c, fx.u("/apps/sprout/settings/storage")) if !strings.Contains(body, "carlos store create") { t.Fatalf("an undeclared app is not told how to ask for a store:\n%s", body) } if strings.Contains(body, `id="store-destroy-confirm"`) { t.Fatalf("an app with no store is offered a destroy form:\n%s", body) } } // ---- the domain attach/detach section of Settings (#82 Task 5) ------------- // // The honesty text ("attached where the app runs — on its box, by an // operator") is retired now that this account's own door does the attaching // (appdomains.go, Task 4): the page must offer that door's form instead of a // box command. And a claim this app holds must show up here the moment it // exists, not only once a registry route has caught up with it — which, // for an ALIAS claim, is never: domainattach.go's own comment is that // minting a route row for an alias is exactly the #129/#148 bare-alias // class this design avoids. // TestSettingsPageOffersAttachFormNotBoxInstructions is the honesty-text // retirement itself. func TestSettingsPageOffersAttachFormNotBoxInstructions(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "sprout") c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains")) if status != http.StatusOK { t.Fatalf("settings page = %d:\n%s", status, body) } if strings.Contains(body, "carlos add --host") { t.Fatalf("the settings page still tells the owner to attach on the box:\n%s", body) } if !strings.Contains(body, `action="/a/bab/apps/sprout/domains/attach"`) { t.Fatalf("the settings page does not post to its own attach door:\n%s", body) } } // TestSettingsPageShowsAnUnroutedClaimAsAttaching exercises the gap the // honesty text used to paper over: kass already has a ready route, so // attaching docs.example.com to it takes the ALIAS branch, which never gets // a registry row — the claim must still render, as its own placeholder row, // not vanish until a route exists to join it to. func TestSettingsPageShowsAnUnroutedClaimAsAttaching(t *testing.T) { const host = "docs.example.com" fx, c, _ := domainAttachedFixture(t, "kass", host) status, _, body := get(t, c, fx.u("/apps/kass/settings/domains")) if status != http.StatusOK { t.Fatalf("settings page = %d:\n%s", status, body) } if !strings.Contains(body, host) { t.Fatalf("an attached claim with no route yet does not appear on its own Settings page:\n%s", body) } if !strings.Contains(body, "custom · attaching") { t.Fatalf("the claim-only row does not say it's still attaching:\n%s", body) } } // TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform is the review // fix: readyRouteHost (domainattach.go) skips Platform rows when deciding // whether an attach can alias, so an app whose only route is its own // platform mint takes the first-route branch server-side and needs the // channel field exactly as a never-shipped app does — `len(Detail.Routes) // == 0` would wrongly hide it, because the platform route makes that slice // non-empty. func TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform(t *testing.T) { fx := newConsoleFixture(t) freshApp(t, fx, "sprout") fx.addRoute(t, registry.Route{Host: "sprout.bab.oncarlos.com", Kind: "static", Status: "ready", App: "sprout", Channel: "stable", Platform: true}) c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains")) if status != http.StatusOK { t.Fatalf("settings page = %d:\n%s", status, body) } if !strings.Contains(body, `id="attach-channel"`) { t.Fatalf("an app whose only route is its platform mint is not offered the channel field:\n%s", body) } } // TestAssembleSettingsListsRoutedDomainsWithoutABucket is the other review // fix: assembleSettings used to return before sv.Domains was ever set on a // bucketless console (s.cfg.Releases == nil), so a routed app's own domain // would vanish from Settings.Domains the moment there was no bucket to // check the never-shipped rename gate against. // // This calls assembleSettings directly rather than through the HTTP page: // a bucketless console panics inside Assemble itself (assembleChannels, // view.go, calls release.Store.List on every app-page tab, Settings // included, with no nil guard) — a real, pre-existing gap, but one that // reaches every app page and is not Task 5's Settings-only bug. Fixing // assembleSettings's own early return is; isolating it here proves that fix // without the unit test tripping over the wider, separate one. func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.Releases = nil acct, err := fx.st.AccountBySqid(fx.sqid) if err != nil || acct == nil { t.Fatalf("fixture account %s: %v", fx.sqid, err) } detail := &AppView{ App: "kass", Domains: []DomainView{{Domain: "kass.carlosapp.dev", Type: "custom"}}, } p := &page{AppName: "kass", Detail: detail} sv := fx.srv.assembleSettings(context.Background(), p, acct) if !sv.NoBucket || sv.StoreUnavailable || sv.MailUnavailable || sv.FeaturesUnavailable { t.Fatalf("bucketless settings flags = NoBucket %v, unavailable %v/%v/%v", sv.NoBucket, sv.StoreUnavailable, sv.MailUnavailable, sv.FeaturesUnavailable) } if len(sv.Domains) != 1 || sv.Domains[0].Domain != "kass.carlosapp.dev" { t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains) } } // ---- Settings tab: the Email card (issue #240) ------------------------------ // // mailCard is object storage's sibling (storeCard, above) — same declare -> // grant -> provision -> rotate shape, same error discipline: a read failure // returns nil so the page says nothing about email at all rather than // guessing, and an absent record returns a zero card, the shape the // empty-state line renders from. Available is the one field that does NOT // come from the record at all: it is s.cfg.MailProvisioner != nil, read // before the record is, because it decides which of the two empty-state // sentences a zero card gets — and a deployment with no provisioner must // never be told to run a command that mailProvisionerGate would 404. // putMailRecord seeds apps///email.json directly — the state the // card has to report on and the walk functions (Declare/ApplyGrant/Enable/ // SetPaused) would normally reach through their own doors. func putMailRecord(t *testing.T, fx *consoleFixture, app string, rec *mail.Record) { t.Helper() if _, err := mail.Put(context.Background(), fx.rel, fx.sqid, app, rec, ""); err != nil { t.Fatalf("seed mail record for %s: %v", app, err) } } // mailCardAccount is the fixture account as mailCard's Account parameter — // only Sqid is read, so a bare literal is enough; AccountBySqid's roundtrip // through the console database would prove nothing extra. func mailCardAccount(fx *consoleFixture) *Account { return &Account{Sqid: fx.sqid} } func TestMailCardStates(t *testing.T) { now := time.Now() cases := []struct { name string app string rec *mail.Record // nil = write nothing provisionerNil bool check func(t *testing.T, card *MailCard) }{ { name: "absent record", app: "mail-absent", check: func(t *testing.T, card *MailCard) { if card == nil { t.Fatal("absent record with no read error returned nil, want a zero card") } if !card.Available { t.Fatal("Available = false with a MailProvisioner configured") } if card.Declared || card.Granted || card.Provisioned || len(card.Standalone) != 0 { t.Fatalf("absent record produced a non-zero card: %+v", card) } }, }, { name: "declared only", app: "mail-declared", rec: &mail.Record{Declared: &mail.Declared{ EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now, By: "paul@keymail.dev", }}, check: func(t *testing.T, card *MailCard) { if !card.Declared || card.EnvPrefix != "CARLOS_SMTP" || card.From != "hi@example.com" { t.Fatalf("declared-only card = %+v", card) } if card.Granted || card.Provisioned { t.Fatalf("declared-only card claims more than declared: %+v", card) } }, }, { name: "granted", app: "mail-granted", rec: &mail.Record{ Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, Source: "tier:free", At: now}, }, check: func(t *testing.T, card *MailCard) { if !card.Granted || card.DailyCap != 500 { t.Fatalf("granted card = %+v", card) } if len(card.Domains) != 1 || card.Domains[0] != "example.com" { t.Fatalf("granted card domains = %+v, want [example.com]", card.Domains) } if card.Provisioned { t.Fatalf("granted-only card claims provisioned: %+v", card) } }, }, { name: "provisioned", app: "mail-provisioned", rec: &mail.Record{ Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, At: now}, Provisioned: &mail.Provisioned{KeyID: "AKIA1", Active: true, At: now.Add(-2 * time.Hour)}, }, check: func(t *testing.T, card *MailCard) { if !card.Provisioned || card.KeyID != "AKIA1" { t.Fatalf("provisioned card = %+v", card) } if card.KeyAge == "" { t.Fatal("provisioned card has no KeyAge") } if card.Paused || card.PendingFinish { t.Fatalf("a healthy provisioned card reads paused or pending-finish: %+v", card) } }, }, { name: "mid-rotation", app: "mail-rotating", rec: &mail.Record{ Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, Provisioned: &mail.Provisioned{KeyID: "AKIA2", PrevKeyID: "AKIA1", Active: true, At: now}, }, check: func(t *testing.T, card *MailCard) { if !card.PendingFinish { t.Fatalf("a record with a PrevKeyID did not set PendingFinish: %+v", card) } }, }, { name: "paused-cap", app: "mail-paused-cap", rec: &mail.Record{ Provisioned: &mail.Provisioned{KeyID: "AKIA3", Active: false, PauseKind: mail.PauseKindCap, At: now}, }, check: func(t *testing.T, card *MailCard) { if !card.Paused || card.PauseKind != mail.PauseKindCap { t.Fatalf("paused-cap card = %+v", card) } }, }, { name: "paused-rate", app: "mail-paused-rate", rec: &mail.Record{ Provisioned: &mail.Provisioned{KeyID: "AKIA4", Active: false, PauseKind: mail.PauseKindRate, At: now}, }, check: func(t *testing.T, card *MailCard) { if !card.Paused || card.PauseKind != mail.PauseKindRate { t.Fatalf("paused-rate card = %+v", card) } }, }, { name: "paused-manual", app: "mail-paused-manual", rec: &mail.Record{ Provisioned: &mail.Provisioned{KeyID: "AKIA5", Active: false, PauseKind: "", At: now}, }, check: func(t *testing.T, card *MailCard) { if !card.Paused || card.PauseKind != "" { t.Fatalf("paused-manual card = %+v", card) } }, }, { name: "with standalone credentials", app: "mail-standalone", rec: &mail.Record{ Standalone: []*mail.Standalone{ {Name: "mailgun-fallback", KeyID: "AKIASTAND", At: now.Add(-24 * time.Hour), By: "paul@keymail.dev"}, }, }, check: func(t *testing.T, card *MailCard) { if len(card.Standalone) != 1 { t.Fatalf("standalone card = %+v", card) } row := card.Standalone[0] if row.Name != "mailgun-fallback" || row.KeyID != "AKIASTAND" || row.Age == "" { t.Fatalf("standalone row = %+v", row) } }, }, { name: "provisioner nil", app: "mail-no-provisioner", provisionerNil: true, check: func(t *testing.T, card *MailCard) { if card == nil { t.Fatal("provisioner-nil, absent record returned nil, want a zero card") } if card.Available { t.Fatal("Available = true with no MailProvisioner configured") } if card.Declared || card.Granted || card.Provisioned { t.Fatalf("provisioner-nil card is not otherwise zero: %+v", card) } }, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { fx := newConsoleFixture(t) if !tc.provisionerNil { fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") } if tc.rec != nil { putMailRecord(t, fx, tc.app, tc.rec) } card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), tc.app) tc.check(t, card) }) } } // unreadableMailStore breaks exactly the email record's read — the state a // bucket hiccup (or a permissions gap on this one object) leaves behind. type unreadableMailStore struct{ release.Store } func (u unreadableMailStore) Get(ctx context.Context, key string) ([]byte, string, error) { if strings.HasSuffix(key, "/email.json") { return nil, "", errors.New("AccessDenied: s3://carlos-releases/apps/.../email.json request id XYZ") } return u.Store.Get(ctx, key) } // TestMailCardReadErrorReturnsNilNotAZeroCard is storeCard's own discipline, // carried over: "could not read the record" and "there is genuinely no // email" must never render the same way. A zero card here would tell a // member "no email for this app yet, get it with carlos email enable" on a // page that could not actually confirm that — mailCard must instead return // nil, which the template renders as nothing at all about email. func TestMailCardReadErrorReturnsNilNotAZeroCard(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") fx.srv.cfg.Releases = unreadableMailStore{fx.rel} card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), "kass") if card != nil { t.Fatalf("a read failure returned %+v, want nil", card) } } // TestSettingsPageShowsTheEmailCard is the render half: a card field the // template never reads does not exist for the member looking at the page, // and the paused state above all else must reach the HTML — a paused app is // sending nothing, and this must be impossible to miss. func TestSettingsPageShowsTheEmailCard(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") putMailRecord(t, fx, "kass", &mail.Record{ Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@kass.example.com", At: time.Now()}, Grant: &mail.GrantBlock{Domains: []string{"kass.example.com"}, DailyCap: 500, At: time.Now()}, Provisioned: &mail.Provisioned{ KeyID: "AKIAPAUSED", Active: false, PauseKind: mail.PauseKindRate, At: time.Now(), }, Standalone: []*mail.Standalone{{Name: "legacy-notifier", KeyID: "AKIASTAND1", At: time.Now()}}, }) c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/settings/email")) if status != http.StatusOK { t.Fatalf("settings page = %d:\n%s", status, body) } for _, want := range []string{ "CARLOS_SMTP", // the env prefix "hi@kass.example.com", // the From address "kass.example.com", // the granted sending domain "AKIAPAUSED", // the SMTP username "AKIASTAND1", // the standalone credential's key id "legacy-notifier", // the standalone credential's name "Sending is paused", // impossible to miss "does not clear itself", // the rate-pause distinction from a cap pause } { if !strings.Contains(body, want) { t.Fatalf("the email card is missing %q:\n%s", want, body) } } // An app nobody has declared email for, on a console WITH a provisioner, // gets the command that can actually run. freshApp(t, fx, "sprout") _, _, body = get(t, c, fx.u("/apps/sprout/settings/email")) if !strings.Contains(body, "carlos email enable --app sprout") { t.Fatalf("an undeclared app is not told how to ask for email:\n%s", body) } } // TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner is the // judgement call the brief calls out: a deployment with no MailProvisioner // must never suggest a command that mailProvisionerGate would 404 — // suggesting `carlos email enable` there would tell the member to run // something that cannot work. func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailProvisioner = nil c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/settings/email")) if status != http.StatusOK { t.Fatalf("settings page = %d:\n%s", status, body) } if !strings.Contains(body, "not available on this deployment") { t.Fatalf("a provisioner-less console does not say email is unavailable:\n%s", body) } if strings.Contains(body, "carlos email enable") { t.Fatalf("a provisioner-less console still suggests a command that would 404:\n%s", body) } } // The Settings page was the last first-party surface still telling the // pre-#299 story: it read THIS box's registry copy and printed a DNS word, // while `carlos domains list` read the fleet-wide sweep and could be saying // something else about the same hostname at the same moment. Both must now // come from domainState over the same inputs. func TestSettingsDomainsCarryTheSameStateTheAPIDoorDerives(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Now() acct, err := fx.st.AccountBySqid(fx.sqid) if err != nil || acct == nil { t.Fatalf("fixture account %s: %v", fx.sqid, err) } if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ Host: "www.customer.test", Account: fx.sqid, App: "kass", }); err != nil { t.Fatal(err) } if err := status.WriteBox(ctx, fx.rel, status.Box{ GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ "www.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()}, }}, }); err != nil { t.Fatal(err) } got := fx.srv.settingsDomains(ctx, acct, "kass", nil) if len(got) != 1 { t.Fatalf("rows = %+v", got) } wantState, wantReason, wantDetail := domainState( status.AliasReading{Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()}, true, nil, now) if got[0].State != wantState || got[0].Reason != wantReason || got[0].Detail != wantDetail { t.Errorf("row = state %q reason %q detail %q; want %q / %q / %q — the page and the CLI must derive one verdict", got[0].State, got[0].Reason, got[0].Detail, wantState, wantReason, wantDetail) } if got[0].Status() != wantState { t.Errorf("Status() = %q, want the derived state %q", got[0].Status(), wantState) } } // A failing certificate reaches the page, through the same warm-outcome // record the API doors read (#299 §3e). This is the surface a member is most // likely to be looking at when it happens. func TestSettingsDomainsShowAFailedCertificate(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Now() acct, err := fx.st.AccountBySqid(fx.sqid) if err != nil || acct == nil { t.Fatalf("fixture account %s: %v", fx.sqid, err) } if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ Host: "broken.customer.test", Account: fx.sqid, App: "kass", }); err != nil { t.Fatal(err) } if err := status.WriteBox(ctx, fx.rel, status.Box{ GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ "broken.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "pointing", DNSCheckedAt: now.Unix()}, }}, }); err != nil { t.Fatal(err) } body, err := certwarm.Record{Host: "broken.customer.test", Attempts: domainFailedAttempts, Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()}.Encode(now) if err != nil { t.Fatal(err) } if _, err := fx.rel.Put(ctx, certwarm.Key("broken.customer.test"), body, ""); err != nil { t.Fatal(err) } got := fx.srv.settingsDomains(ctx, acct, "kass", nil) if len(got) != 1 || got[0].State != StateFailed { t.Fatalf("rows = %+v, want one row in state %q", got, StateFailed) } // And it EXPLAINS. A one-word "failed" with no sentence under it is the // worst version of this row. if got[0].Explain() == "" { t.Error("a failed certificate row carries no explanation") } } // Explain is what keeps the page readable: a working domain gets no // paragraph, because a sentence repeated under every healthy row trains // people to skip the one under the broken row. func TestDomainViewExplainStaysQuietWhenThereIsNothingToSay(t *testing.T) { for _, tc := range []struct { name string d DomainView want string }{ {"live says nothing", DomainView{State: StateLive, Detail: "served over HTTPS"}, ""}, {"a problem says its detail", DomainView{State: StateMisdirected, Detail: "points elsewhere"}, "points elsewhere"}, {"a problem with records says both", DomainView{State: StatePendingDNS, Detail: "no record yet", Guidance: "Point an A record."}, "no record yet Point an A record."}, // A row the shared view builder produced, with no fleet fold behind // it, must render exactly as it always has. {"no state falls back to guidance", DomainView{Guidance: "Point an A record."}, "Point an A record."}, {"no state and no guidance", DomainView{}, ""}, } { if got := tc.d.Explain(); got != tc.want { t.Errorf("%s: Explain() = %q, want %q", tc.name, got, tc.want) } } }