package console import ( "fmt" "net/http" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" ) // Account existence must not be probeable: a signed-in non-member asking for // an account URL gets the BYTE-IDENTICAL response a nonexistent page gets. // Same assertion shape as TestFleetOperatorOnly — compare the full body // against a genuinely unrouted path. func TestNonMemberGetsIdenticalNotFound(t *testing.T) { fx := newConsoleFixture(t) // paul owns the fixture account; bob is signed in but is not a member. fx.setUser("bob@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-bob") memberStatus, _, memberBody := get(t, c, fx.u("/")) ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page") if memberStatus != http.StatusNotFound || memberStatus != ghostStatus { t.Fatalf("non-member account URL = %d, nonexistent page = %d; want both 404", memberStatus, ghostStatus) } if memberBody != ghostBody { t.Fatalf("non-member 404 body differs from a nonexistent page's:\n%q\n%q", memberBody, ghostBody) } // An account that does not exist AT ALL answers identically, so you cannot // tell "not yours" from "not real". if _, _, body := get(t, c, fx.ts.URL+"/a/zzzz/"); body != ghostBody { t.Fatalf("nonexistent account body differs: %q", body) } // And the mutation routes are gated the same way. st, _, _ := postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"channel": {"stable"}, "version": {"v1"}}) if st != http.StatusNotFound { t.Errorf("non-member promote = %d, want 404", st) } } // A member can address an account by its name; the page is the same page // its sqid serves. func TestAccountNameResolvesForMember(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-1") id := testAccountID() sqid := accountSqid(id) if err := fx.st.CreateAccount(id, sqid, "amadan", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } st, _, byName := get(t, c, fx.ts.URL+"/a/amadan/") if st != http.StatusOK { t.Fatalf("GET /a/amadan/ = %d, want 200", st) } if !strings.Contains(byName, "amadan") { t.Fatalf("name-resolved page does not mention the account") } } func TestLandingRedirectsToLastAccount(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-1") // Signing up lands somewhere; visiting the fixture account sets the MRU. if st, _, _ := get(t, c, fx.u("/")); st != http.StatusOK { t.Fatalf("account home = %d", st) } // A second account, visited after, becomes the MRU. id := testAccountID() sqid := accountSqid(id) if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK { t.Fatalf("second account home = %d", st) } st, loc, _ := get(t, c, fx.ts.URL+"/") if st != http.StatusFound || loc != "/a/"+sqid+"/" { t.Fatalf("landing = %d -> %q, want 302 -> /a/%s/", st, loc, sqid) } } // Landing prefers the readable URL: a viewer whose account has a unique // slug-shaped name lands on /a//, and the page's nav links use it, // while its forms keep posting to the sqid path. func TestLandingPrefersNameSegment(t *testing.T) { fx := newConsoleFixture(t) fx.setUser("solo@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-solo") id := testAccountID() sqid := accountSqid(id) if err := fx.st.CreateAccount(id, sqid, "amadan", "solo@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK { t.Fatal("visit to set MRU") } st, loc, _ := get(t, c, fx.ts.URL+"/") if st != http.StatusFound || loc != "/a/amadan/" { t.Fatalf("landing = %d -> %q, want 302 -> /a/amadan/", st, loc) } _, _, body := get(t, c, fx.ts.URL+"/a/amadan/") if !strings.Contains(body, `href="/a/amadan/"`) { t.Errorf("nav links should use the name segment") } // The account is empty, so its delete form (the only sqid-posting form // left, now that settings owns it) lives on /settings, not the home page. _, _, settings := get(t, c, fx.ts.URL+"/a/amadan/settings") if !strings.Contains(settings, `action="/a/`+sqid+`/`) { t.Errorf("form actions must stay on the sqid path") } } // The switcher's non-current rows must link somewhere real on every page that // renders it — not just the pages that happen to load Accounts through // accountPage. /accounts/new fills its own Accounts (for the switcher's // trigger, not chrome accountPage builds), and it must fill Seg on each row // too, or the link degenerates to href="/a//" for any account whose name // isn't its own preferred segment. func TestSwitcherLinksUsePreferredSegmentsEverywhere(t *testing.T) { fx := newConsoleFixture(t) fx.setUser("duo@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-duo") // mints a personal account named by address (not slug-shaped) accts, err := fx.st.AccountsFor("duo@keymail.dev") if err != nil || len(accts) != 1 { t.Fatalf("AccountsFor = %+v %v, want one minted account", accts, err) } personalSqid := accts[0].Sqid id := testAccountID() sqid := accountSqid(id) if err := fx.st.CreateAccount(id, sqid, "amadan", "duo@keymail.dev", time.Now()); err != nil { t.Fatal(err) } // On the personal account's own page, "amadan" is the non-current row — // its href must be the name, not a dead "/a//". if st, _, _ := get(t, c, fx.ts.URL+"/a/"+personalSqid+"/"); st != http.StatusOK { t.Fatalf("personal account page = %d", st) } _, _, body := get(t, c, fx.ts.URL+"/a/"+personalSqid+"/") if !strings.Contains(body, `href="/a/amadan/"`) { t.Errorf("account page switcher: non-current row for the named account has no usable href:\n%s", body) } // /accounts/new builds its own Accounts list (not through accountPage). // The MRU is still the personal account, so "amadan" is again the // non-current row there too. _, _, body = get(t, c, fx.ts.URL+"/accounts/new") if !strings.Contains(body, `href="/a/amadan/"`) { t.Errorf("/accounts/new switcher: non-current row for the named account has no usable href:\n%s", body) } if strings.Contains(body, `href="/a//"`) { t.Errorf("/accounts/new switcher rendered a dead href:\n%s", body) } } // A user with no memberships at all gets a personal account minted on the // spot — with a bucket manifest, so every box materializes the same identity. // A name the viewer cannot resolve — someone ELSE's account — answers with // the byte-identical 404 an unknown page gets, exactly as sqids do. func TestAccountNameNotProbeable(t *testing.T) { fx := newConsoleFixture(t) id := testAccountID() if err := fx.st.CreateAccount(id, accountSqid(id), "secretco", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } fx.setUser("bob@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-bob") st, _, body := get(t, c, fx.ts.URL+"/a/secretco/") _, _, ghost := get(t, c, fx.ts.URL+"/definitely-not-a-page") if st != http.StatusNotFound || body != ghost { t.Fatalf("non-member name lookup: status %d, body match %v — want the byte-identical 404", st, body == ghost) } } // Two memberships sharing a name: GET renders the picker naming both sqids; // a POST to the ambiguous segment is refused with the same 404 — even with a // good CSRF token and an owner behind it, so the refusal is resolution's and // not some other gate's. func TestAmbiguousNameRendersPicker(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-1") csrf := sessionToken(t, fx, c) idA, idB := testAccountID(), testAccountID() sqidA, sqidB := accountSqid(idA), accountSqid(idB) for _, x := range []struct { id int64 sqid string }{{idA, sqidA}, {idB, sqidB}} { if err := fx.st.CreateAccount(x.id, x.sqid, "amadan", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } } st, _, body := get(t, c, fx.ts.URL+"/a/amadan/") if st != http.StatusOK { t.Fatalf("ambiguous GET = %d, want 200 picker", st) } if !strings.Contains(body, "/a/"+sqidA+"/") || !strings.Contains(body, "/a/"+sqidB+"/") { t.Fatalf("picker must link both candidates by sqid:\n%s", body) } if st, _, _ := postForm(t, c, fx.ts.URL+"/a/amadan/invite", url.Values{"csrf": {csrf}, "address": {"x@keymail.dev"}}); st != http.StatusNotFound { t.Errorf("POST to ambiguous segment = %d, want 404", st) } // And the refused POST did nothing: no invite in either account. for _, id := range []int64{idA, idB} { if pending, _ := fx.st.PendingInvites(id); len(pending) != 0 { t.Errorf("ambiguous POST still invited into account %d: %+v", id, pending) } } } // A name that equals another membership's SQID is ambiguous the same way — // symmetric resolution never silently prefers one space over the other. func TestNameSqidCollisionRendersPicker(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-1") idA := testAccountID() sqidA := accountSqid(idA) if err := fx.st.CreateAccount(idA, sqidA, "amadan", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } idB := testAccountID() if err := fx.st.CreateAccount(idB, accountSqid(idB), sqidA, "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } st, _, body := get(t, c, fx.ts.URL+"/a/"+sqidA+"/") if st != http.StatusOK || !strings.Contains(body, "/a/"+accountSqid(idB)+"/") { t.Fatalf("sqid-vs-name collision: status %d — want the picker listing both", st) } } func TestLandingMintsPersonalAccountWhenNoMemberships(t *testing.T) { fx := newConsoleFixture(t) fx.setUser("newbie@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-new") st, loc, _ := get(t, c, fx.ts.URL+"/") if st != http.StatusFound || !strings.HasPrefix(loc, "/a/") { t.Fatalf("landing = %d -> %q, want 302 into an account", st, loc) } accts, err := fx.st.AccountsFor("newbie@keymail.dev") if err != nil || len(accts) != 1 { t.Fatalf("AccountsFor = %+v %v, want one minted account", accts, err) } if accts[0].Role != RoleOwner { t.Errorf("minted account role = %q, want owner", accts[0].Role) } if loc != "/a/"+accts[0].Sqid+"/" { t.Errorf("landed at %q, want /a/%s/", loc, accts[0].Sqid) } // The manifest is in the bucket, and its sqid is the frozen encoding. if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(accts[0].Sqid)); err != nil { t.Fatalf("no account manifest in the bucket: %v", err) } if accountSqid(accts[0].ID) != accts[0].Sqid { t.Errorf("minted sqid %q is not Sqid(%d)", accts[0].Sqid, accts[0].ID) } // Landing again does NOT mint a second one. get(t, c, fx.ts.URL+"/") if accts, _ := fx.st.AccountsFor("newbie@keymail.dev"); len(accts) != 1 { t.Errorf("a second landing minted another account: %+v", accts) } } func TestOwnerOnlyActionsRefuseMembers(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") csrf := sessionToken(t, fx, c) // A member CAN deploy... if st, _, _ := get(t, c, fx.u("/apps/kass")); st != http.StatusOK { t.Fatalf("member app page = %d, want 200", st) } // ...but cannot invite or revoke: owner-only actions answer with the same // 404 a non-member gets, so a member cannot even probe them. st, _, _ := postForm(t, c, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if st != http.StatusNotFound { t.Errorf("member invite = %d, want 404", st) } if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 { t.Errorf("a member's refused invite still created one: %+v", pending) } st, _, _ = postForm(t, c, fx.u("/invite/revoke"), url.Values{"csrf": {csrf}, "token": {"whatever"}}) if st != http.StatusNotFound { t.Errorf("member revoke = %d, want 404", st) } } func TestInviteAcceptCreatesMembership(t *testing.T) { fx := newConsoleFixture(t) owner := newBrowser(t) signUp(t, fx, owner, "code-owner") csrf := sessionToken(t, fx, owner) st, loc, _ := postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}}) if st != http.StatusSeeOther || loc != fx.path("/team") { t.Fatalf("invite = %d -> %q", st, loc) } pending, _ := fx.st.PendingInvites(OpsAccountID) if len(pending) != 1 || pending[0].Address != "sam@keymail.dev" { t.Fatalf("PendingInvites = %+v", pending) } // The link went into sam's Keymail inbox by federation. sent := fx.fk.fedBodies() if len(sent) == 0 || !strings.Contains(strings.Join(sent, "\n"), "/invite/"+pending[0].Token) { t.Fatalf("invite link not delivered through keymail: %v", sent) } // Sam signs in and opens the link: a page that asks, and changes nothing // (#219 item 4 — a GET that joined let any page add a signed-in person to // the inviter's account and switch them into it). fx.setUser("sam@keymail.dev") sam := newBrowser(t) signUp(t, fx, sam, "code-sam") inviteURL := fx.ts.URL + "/invite/" + pending[0].Token st, _, body := get(t, sam, inviteURL) if st != http.StatusOK || !strings.Contains(body, `action="/invite/`+pending[0].Token+`"`) { t.Fatalf("invite link = %d, want 200 and a confirm form:\n%s", st, body) } if role, _ := fx.st.Role(OpsAccountID, "sam@keymail.dev"); role != "" { t.Fatalf("opening the invite link joined the account (role %q)", role) } // A cross-site POST, without the session's CSRF token, is refused. if st, _, _ := postForm(t, sam, inviteURL, url.Values{}); st != http.StatusForbidden { t.Fatalf("accept without csrf = %d, want 403", st) } if role, _ := fx.st.Role(OpsAccountID, "sam@keymail.dev"); role != "" { t.Fatalf("a POST without csrf joined the account (role %q)", role) } // The button. samCSRF := sessionToken(t, fx, sam) st, loc, _ = postForm(t, sam, inviteURL, url.Values{"csrf": {samCSRF}}) if st != http.StatusSeeOther || loc != fx.path("/") { t.Fatalf("accept = %d -> %q, want 303 -> the account", st, loc) } if role, _ := fx.st.Role(OpsAccountID, "sam@keymail.dev"); role != RoleMember { t.Fatalf("role after accept = %q, want member", role) } if st, _, _ := get(t, sam, fx.u("/")); st != http.StatusOK { t.Errorf("accepted member cannot see the account: %d", st) } // Single use, on both the page and the button. if st, _, _ := get(t, sam, inviteURL); st != http.StatusGone { t.Errorf("used invite page = %d, want 410", st) } if st, _, _ := postForm(t, sam, inviteURL, url.Values{"csrf": {samCSRF}}); st == http.StatusSeeOther { t.Error("invite link worked twice") } } func TestSwitcherListsOnlyMyAccounts(t *testing.T) { fx := newConsoleFixture(t) // An account paul is not a member of. otherID := testAccountID() if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Someone Else", "sam@keymail.dev", time.Now()); err != nil { t.Fatal(err) } mineID := testAccountID() if err := fx.st.CreateAccount(mineID, accountSqid(mineID), "Mine", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } c := newBrowser(t) signUp(t, fx, c, "code-1") _, _, body := get(t, c, fx.u("/")) if !strings.Contains(body, "Mine") { t.Error("switcher is missing an account the user belongs to") } if strings.Contains(body, "Someone Else") { t.Errorf("switcher lists an account the user is not a member of:\n%s", body) } if !strings.Contains(body, "/a/"+accountSqid(mineID)+"/") { t.Error("switcher has no link to the user's other account") } } // The switcher's filter box appears once the list is long enough to need it, // and not before: a short list is quicker to read than to search. func TestSwitcherFilterOnlyForLongLists(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-1") accts, err := fx.st.AccountsFor("paul@keymail.dev") if err != nil { t.Fatal(err) } for n := len(accts); n < 7; n++ { id := testAccountID() if err := fx.st.CreateAccount(id, accountSqid(id), fmt.Sprintf("short%d", n), "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } } _, _, body := get(t, c, fx.u("/")) if strings.Contains(header(t, body), "switcher-find") { t.Errorf("seven accounts rendered the filter box:\n%s", header(t, body)) } id := testAccountID() if err := fx.st.CreateAccount(id, accountSqid(id), "eighth", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } _, _, body = get(t, c, fx.u("/")) h := header(t, body) if !strings.Contains(h, `class="switcher-find"`) || !strings.Contains(h, `class="switcher-none" hidden`) { t.Errorf("eight accounts rendered no filter box:\n%s", h) } } // header returns everything between
and
— the chrome, on // its own, so a switcher assertion cannot be satisfied by the page body // happening to contain an account name. func header(t *testing.T, body string) string { t.Helper() _, rest, ok := strings.Cut(body, "
") if !ok { t.Fatalf("no
in:\n%s", body) } head, _, ok := strings.Cut(rest, "
") if !ok { t.Fatalf("unterminated
in:\n%s", body) } return head } // The switcher IS the top-left of the header: a
/ // disclosure naming the current account, in place of the old wordmark. The // disclosure element matters — it is what makes the dropdown keyboard // operable and screen-reader sane with no JavaScript at all. func TestHeaderSwitcherIsADropdownNamingTheCurrentAccount(t *testing.T) { fx := newConsoleFixture(t) otherID := testAccountID() if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Kass", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/")) h := header(t, body) if !strings.Contains(h, "
disclosure:\n%s", h) } if !strings.Contains(h, " trigger:\n%s", h) } // The trigger says what it is — the current account's name, not an icon. summary, _, _ := strings.Cut(h, "
") if !strings.Contains(summary, "ops") { t.Errorf("the switcher trigger does not name the current account:\n%s", summary) } // The wordmark it replaces is gone from an account page's header. if strings.Contains(h, "CARLOS console") { t.Errorf("the wordmark is still in the account page header:\n%s", h) } // The current account is marked as such, and the others are plain links. if !strings.Contains(h, `aria-current="true"`) { t.Errorf("the current account is not marked in the menu:\n%s", h) } if !strings.Contains(h, `href="/a/`+accountSqid(otherID)+`/"`) { t.Errorf("the menu has no link to the user's other account:\n%s", h) } // And "New account" is a link to its own screen, not an inline form. if !strings.Contains(h, `href="/accounts/new"`) { t.Errorf("the menu has no link to the new-account screen:\n%s", h) } if strings.Contains(h, `action="/accounts/new"`) { t.Errorf("the header still carries the inline new-account form:\n%s", h) } } // A user with exactly one account still gets a coherent header: the // dropdown names it, marks it current, and still offers a new account. func TestSingleAccountHeaderIsCoherent(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/")) h := header(t, body) if accts, _ := fx.st.AccountsFor("paul@keymail.dev"); len(accts) != 1 { t.Fatalf("fixture user has %d accounts, want exactly 1", len(accts)) } if !strings.Contains(h, " %q, want 302 -> /", st, loc) } c := signInPaul(t, fx) st, _, body := get(t, c, fx.ts.URL+"/accounts/new") if st != http.StatusOK { t.Fatalf("/accounts/new = %d, want 200", st) } if !strings.Contains(body, `action="/accounts/new"`) || !strings.Contains(body, `name="csrf"`) { t.Fatalf("the new-account screen has no CSRF-bearing form:\n%s", body) } // It is a page, not a header ornament: the chrome is still there. if !strings.Contains(header(t, body), "
%q, want 303", st, loc) } accts, _ := fx.st.AccountsFor("paul@keymail.dev") var made *Account for i := range accts { if accts[i].Name == "woodstar" { made = &accts[i] } } if made == nil { t.Fatalf("AccountsFor = %+v, want a woodstar", accts) } if loc != "/a/"+made.Sqid+"/" { t.Fatalf("landed at %q, want /a/%s/", loc, made.Sqid) } // And the account you landed in is the one the header now names. _, _, body = get(t, c, fx.ts.URL+loc) if h := header(t, body); !strings.Contains(h, "woodstar") { t.Errorf("the header does not name the account just created:\n%s", h) } } // A nameless submission is a mistake to correct on the screen you are on, // not a bounce back to some other account. func TestNewAccountScreenRejectsAnEmptyNameInPlace(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {" "}}) if st != http.StatusSeeOther || loc != "/accounts/new" { t.Fatalf("empty name = %d -> %q, want 303 -> /accounts/new", st, loc) } if _, _, body := get(t, c, fx.ts.URL+"/accounts/new"); !strings.Contains(body, "Account names appear in URLs") { t.Errorf("the screen does not say why it refused:\n%s", body) } } // The three edges where a human types a name all refuse a non-slug; the // auto-mint path (address-named personal accounts) must NOT be validated — // a mint that can refuse strands a signed-in user. func TestNewAccountNameValidated(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-1") csrf := sessionToken(t, fx, c) before, err := fx.st.AccountsFor("paul@keymail.dev") if err != nil { t.Fatal(err) } st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {"Paul's Stuff!"}}) if st != http.StatusSeeOther || loc != "/accounts/new" { t.Fatalf("bad name = %d -> %q, want 303 back to the form", st, loc) } after, err := fx.st.AccountsFor("paul@keymail.dev") if err != nil { t.Fatal(err) } if len(after) != len(before) { t.Fatal("a rejected name must not create an account") } if st, _, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {"amadan"}}); st != http.StatusSeeOther { t.Fatalf("good name = %d, want 303 into the new account", st) } } // Visiting an account page updates the MRU; visiting /accounts/new does not // — it is not an account you are "in", and moving the MRU there would send // the next sign-in somewhere the user never chose. func TestNewAccountScreenLeavesTheMRUAlone(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) id := testAccountID() sqid := accountSqid(id) if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK { t.Fatalf("second account home = %d", st) } if st, _, _ := get(t, c, fx.ts.URL+"/accounts/new"); st != http.StatusOK { t.Fatalf("/accounts/new = %d", st) } if st, loc, _ := get(t, c, fx.ts.URL+"/"); st != http.StatusFound || loc != "/a/"+sqid+"/" { t.Fatalf("landing = %d -> %q, want the MRU account /a/%s/", st, loc, sqid) } } // The audit trail is per (account, app), not per app name. Two accounts can // both own a "blog"; sharing one activity stream would show each account the // other's actions — including the other account's member addresses, which is // a membership leak dressed up as a convenience. func TestAuditIsScopedToTheAccount(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() other := testAccountID() if err := fx.st.CreateAccount(other, accountSqid(other), "Other", "sam@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(other, "kass", "sam@keymail.dev", now); err != nil { t.Fatal(err) } if err := fx.st.RecordEvent(OpsAccountID, "kass", "paul@keymail.dev", "promote", "promoted v1 to stable", now); err != nil { t.Fatal(err) } if err := fx.st.RecordEvent(other, "kass", "sam@keymail.dev", "promote", "promoted v9 to stable", now); err != nil { t.Fatal(err) } ours, err := fx.st.Events(OpsAccountID, "kass", 10) if err != nil { t.Fatal(err) } if len(ours) != 1 || ours[0].Address != "paul@keymail.dev" { t.Fatalf("Events(ops, kass) = %+v, want only our own", ours) } theirs, _ := fx.st.Events(other, "kass", 10) if len(theirs) != 1 || theirs[0].Address != "sam@keymail.dev" { t.Fatalf("Events(other, kass) = %+v", theirs) } // And through the page, which is where the leak would actually be seen. c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass/activity")) if !strings.Contains(body, "promoted v1 to stable") { t.Error("the account's own activity is missing from its activity tab") } if strings.Contains(body, "sam@keymail.dev") || strings.Contains(body, "promoted v9 to stable") { t.Errorf("another account's activity leaked onto its activity tab:\n%s", body) } } // The silent blackhole: `carlos accounts migrate` re-stamps the ROUTES, and // if `console migrate-app` is then skipped, the console promotes into one // account's keys while every host reads another's. "Promoted v2 to stable" // flashes green and nothing deploys, forever, with no error anywhere. // // So the app page says so, and points at the fix: the overview and releases // tabs both link to Settings, which is where the repair itself now lives // (appsettings.go) — a button when the signed-in owner can reach the target // account, the operator command as a fallback when they cannot. func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) // Agreeing (the fixture's routes carry no account, which reads as ops): // no warning, or the warning is worthless. _, _, body := get(t, c, fx.u("/apps/kass")) if strings.Contains(body, "not deploying") { t.Fatalf("warned about an app whose accounts agree:\n%s", body) } // Now the routes move to another account and the console row is left // behind — exactly the half-migrated state. reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } moved := testAccountID() sqid := accountSqid(moved) if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if err := fx.st.AddMember(moved, "paul@keymail.dev", RoleOwner, time.Now()); err != nil { t.Fatal(err) } for _, host := range []string{"kass.carlosapp.dev", "docs.kass.carlosapp.dev"} { if err := reg.SetAccount(host, sqid); err != nil { t.Fatal(err) } } reg.Close() _, _, body = get(t, c, fx.u("/apps/kass")) if !strings.Contains(body, `/apps/kass/account-review`) { t.Errorf("no warning on a half-migrated app:\n%s", body) } // The promote/rollback/canary forms live on the releases tab, not the // overview — a deep link there must not promote blind. Same warning, // same fix, on the page that actually hosts the mutations. _, _, body = get(t, c, fx.u("/apps/kass/releases")) if !strings.Contains(body, `/apps/kass/account-review`) { t.Errorf("no warning on the releases tab of a half-migrated app:\n%s", body) } // Settings itself holds the actual repair. paul owns the target account // here (CreateAccount above makes its creator the owner), so he can reach // it — the button, not the operator-command fallback, is what he's owed. _, _, body = get(t, c, fx.u("/apps/kass/settings")) if !strings.Contains(body, "Move to "+sqid) { t.Errorf("Settings does not offer the move-to-target button for an owner who can reach it:\n%s", body) } if !strings.Contains(body, `action="`+fx.path("/apps/kass/move")+`"`) { t.Errorf("Settings' move button does not post to the move endpoint:\n%s", body) } } // Creating an account from the console: minted id, bucket manifest, owner // membership, and you land in it. func TestCreateAccountFromConsole(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-1") csrf := sessionToken(t, fx, c) st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {"kass"}}) if st != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") { t.Fatalf("create account = %d -> %q", st, loc) } accts, _ := fx.st.AccountsFor("paul@keymail.dev") var made *Account for i := range accts { if accts[i].Name == "kass" { made = &accts[i] } } if made == nil || made.Role != RoleOwner { t.Fatalf("AccountsFor = %+v, want an owned kass", accts) } if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(made.Sqid)); err != nil { t.Errorf("no manifest for the new account: %v", err) } if loc != "/a/"+made.Sqid+"/" { t.Errorf("landed at %q, want the new account", loc) } } // App names are unique per ACCOUNT: the same name in two accounts is two // different apps, with two different bucket prefixes. func TestSameAppNameInTwoAccounts(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-1") csrf := sessionToken(t, fx, c) id := testAccountID() sqid := accountSqid(id) if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } for _, base := range []string{fx.path(""), "/a/" + sqid} { st, _, _ := postForm(t, c, fx.ts.URL+base+"/apps/new", url.Values{"csrf": {csrf}, "name": {"blog"}}) if st != http.StatusSeeOther { t.Fatalf("claim blog under %s = %d", base, st) } } for _, acct := range []int64{OpsAccountID, id} { apps, err := fx.st.AppsIn(acct) if err != nil || len(apps) == 0 { t.Fatalf("AppsIn(%d) = %v %v", acct, apps, err) } found := false for _, a := range apps { if a == "blog" { found = true } } if !found { t.Errorf("account %d has no blog: %v", acct, apps) } } } // The account nav grows Apps · Team · Settings, account-scoped and marking // whichever section the page sits in, and the signed-in address becomes the // door to personal settings (registered later this branch). func TestAccountNavHasSections(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) _, home, _ := get(t, c, fx.ts.URL+"/") nav := strings.TrimSuffix(home, "/") _, _, body := get(t, c, fx.ts.URL+home) h := header(t, body) for _, want := range []string{ `href="` + nav + `/"`, `href="` + nav + `/team"`, `href="` + nav + `/settings"`, `href="/settings"`, // the address is the door to personal settings } { if !strings.Contains(h, want) { t.Fatalf("header missing %q in:\n%s", want, h) } } if strings.Count(h, `aria-current="page"`) != 1 { t.Fatalf("want exactly one active nav section on the apps page, got:\n%s", h) } _, _, team := get(t, c, fx.ts.URL+nav+"/team") if !strings.Contains(header(t, team), `/team" aria-current="page"`) { t.Fatal("team page does not mark Team active") } }