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)
}
}
}