carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/registry"
12)
13
14// Account existence must not be probeable: a signed-in non-member asking for
15// an account URL gets the BYTE-IDENTICAL response a nonexistent page gets.
16// Same assertion shape as TestFleetOperatorOnly — compare the full body
17// against a genuinely unrouted path.
18func TestNonMemberGetsIdenticalNotFound(t *testing.T) {
19 fx := newConsoleFixture(t)
20 // paul owns the fixture account; bob is signed in but is not a member.
21 fx.setUser("bob@keymail.dev")
22 c := newBrowser(t)
23 signUp(t, fx, c, "code-bob")
24
25 memberStatus, _, memberBody := get(t, c, fx.u("/"))
26 ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
27 if memberStatus != http.StatusNotFound || memberStatus != ghostStatus {
28 t.Fatalf("non-member account URL = %d, nonexistent page = %d; want both 404", memberStatus, ghostStatus)
29 }
30 if memberBody != ghostBody {
31 t.Fatalf("non-member 404 body differs from a nonexistent page's:\n%q\n%q", memberBody, ghostBody)
32 }
33 // An account that does not exist AT ALL answers identically, so you cannot
34 // tell "not yours" from "not real".
35 if _, _, body := get(t, c, fx.ts.URL+"/a/zzzz/"); body != ghostBody {
36 t.Fatalf("nonexistent account body differs: %q", body)
37 }
38 // And the mutation routes are gated the same way.
39 st, _, _ := postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"channel": {"stable"}, "version": {"v1"}})
40 if st != http.StatusNotFound {
41 t.Errorf("non-member promote = %d, want 404", st)
42 }
43}
44
45// A member can address an account by its name; the page is the same page
46// its sqid serves.
47func TestAccountNameResolvesForMember(t *testing.T) {
48 fx := newConsoleFixture(t)
49 c := newBrowser(t)
50 signUp(t, fx, c, "code-1")
51 id := testAccountID()
52 sqid := accountSqid(id)
53 if err := fx.st.CreateAccount(id, sqid, "amadan", "paul@keymail.dev", time.Now()); err != nil {
54 t.Fatal(err)
55 }
56 st, _, byName := get(t, c, fx.ts.URL+"/a/amadan/")
57 if st != http.StatusOK {
58 t.Fatalf("GET /a/amadan/ = %d, want 200", st)
59 }
60 if !strings.Contains(byName, "amadan") {
61 t.Fatalf("name-resolved page does not mention the account")
62 }
63}
64
65func TestLandingRedirectsToLastAccount(t *testing.T) {
66 fx := newConsoleFixture(t)
67 c := newBrowser(t)
68 signUp(t, fx, c, "code-1")
69
70 // Signing up lands somewhere; visiting the fixture account sets the MRU.
71 if st, _, _ := get(t, c, fx.u("/")); st != http.StatusOK {
72 t.Fatalf("account home = %d", st)
73 }
74 // A second account, visited after, becomes the MRU.
75 id := testAccountID()
76 sqid := accountSqid(id)
77 if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil {
78 t.Fatal(err)
79 }
80 if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK {
81 t.Fatalf("second account home = %d", st)
82 }
83 st, loc, _ := get(t, c, fx.ts.URL+"/")
84 if st != http.StatusFound || loc != "/a/"+sqid+"/" {
85 t.Fatalf("landing = %d -> %q, want 302 -> /a/%s/", st, loc, sqid)
86 }
87}
88
89// Landing prefers the readable URL: a viewer whose account has a unique
90// slug-shaped name lands on /a/<name>/, and the page's nav links use it,
91// while its forms keep posting to the sqid path.
92func TestLandingPrefersNameSegment(t *testing.T) {
93 fx := newConsoleFixture(t)
94 fx.setUser("solo@keymail.dev")
95 c := newBrowser(t)
96 signUp(t, fx, c, "code-solo")
97 id := testAccountID()
98 sqid := accountSqid(id)
99 if err := fx.st.CreateAccount(id, sqid, "amadan", "solo@keymail.dev", time.Now()); err != nil {
100 t.Fatal(err)
101 }
102 if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK {
103 t.Fatal("visit to set MRU")
104 }
105 st, loc, _ := get(t, c, fx.ts.URL+"/")
106 if st != http.StatusFound || loc != "/a/amadan/" {
107 t.Fatalf("landing = %d -> %q, want 302 -> /a/amadan/", st, loc)
108 }
109 _, _, body := get(t, c, fx.ts.URL+"/a/amadan/")
110 if !strings.Contains(body, `href="/a/amadan/"`) {
111 t.Errorf("nav links should use the name segment")
112 }
113 // The account is empty, so its delete form (the only sqid-posting form
114 // left, now that settings owns it) lives on /settings, not the home page.
115 _, _, settings := get(t, c, fx.ts.URL+"/a/amadan/settings")
116 if !strings.Contains(settings, `action="/a/`+sqid+`/`) {
117 t.Errorf("form actions must stay on the sqid path")
118 }
119}
120
121// The switcher's non-current rows must link somewhere real on every page that
122// renders it — not just the pages that happen to load Accounts through
123// accountPage. /accounts/new fills its own Accounts (for the switcher's
124// trigger, not chrome accountPage builds), and it must fill Seg on each row
125// too, or the link degenerates to href="/a//" for any account whose name
126// isn't its own preferred segment.
127func TestSwitcherLinksUsePreferredSegmentsEverywhere(t *testing.T) {
128 fx := newConsoleFixture(t)
129 fx.setUser("duo@keymail.dev")
130 c := newBrowser(t)
131 signUp(t, fx, c, "code-duo") // mints a personal account named by address (not slug-shaped)
132
133 accts, err := fx.st.AccountsFor("duo@keymail.dev")
134 if err != nil || len(accts) != 1 {
135 t.Fatalf("AccountsFor = %+v %v, want one minted account", accts, err)
136 }
137 personalSqid := accts[0].Sqid
138
139 id := testAccountID()
140 sqid := accountSqid(id)
141 if err := fx.st.CreateAccount(id, sqid, "amadan", "duo@keymail.dev", time.Now()); err != nil {
142 t.Fatal(err)
143 }
144
145 // On the personal account's own page, "amadan" is the non-current row —
146 // its href must be the name, not a dead "/a//".
147 if st, _, _ := get(t, c, fx.ts.URL+"/a/"+personalSqid+"/"); st != http.StatusOK {
148 t.Fatalf("personal account page = %d", st)
149 }
150 _, _, body := get(t, c, fx.ts.URL+"/a/"+personalSqid+"/")
151 if !strings.Contains(body, `href="/a/amadan/"`) {
152 t.Errorf("account page switcher: non-current row for the named account has no usable href:\n%s", body)
153 }
154
155 // /accounts/new builds its own Accounts list (not through accountPage).
156 // The MRU is still the personal account, so "amadan" is again the
157 // non-current row there too.
158 _, _, body = get(t, c, fx.ts.URL+"/accounts/new")
159 if !strings.Contains(body, `href="/a/amadan/"`) {
160 t.Errorf("/accounts/new switcher: non-current row for the named account has no usable href:\n%s", body)
161 }
162 if strings.Contains(body, `href="/a//"`) {
163 t.Errorf("/accounts/new switcher rendered a dead href:\n%s", body)
164 }
165}
166
167// A user with no memberships at all gets a personal account minted on the
168// spot — with a bucket manifest, so every box materializes the same identity.
169// A name the viewer cannot resolve — someone ELSE's account — answers with
170// the byte-identical 404 an unknown page gets, exactly as sqids do.
171func TestAccountNameNotProbeable(t *testing.T) {
172 fx := newConsoleFixture(t)
173 id := testAccountID()
174 if err := fx.st.CreateAccount(id, accountSqid(id), "secretco", "paul@keymail.dev", time.Now()); err != nil {
175 t.Fatal(err)
176 }
177 fx.setUser("bob@keymail.dev")
178 c := newBrowser(t)
179 signUp(t, fx, c, "code-bob")
180 st, _, body := get(t, c, fx.ts.URL+"/a/secretco/")
181 _, _, ghost := get(t, c, fx.ts.URL+"/definitely-not-a-page")
182 if st != http.StatusNotFound || body != ghost {
183 t.Fatalf("non-member name lookup: status %d, body match %v — want the byte-identical 404", st, body == ghost)
184 }
185}
186
187// Two memberships sharing a name: GET renders the picker naming both sqids;
188// a POST to the ambiguous segment is refused with the same 404 — even with a
189// good CSRF token and an owner behind it, so the refusal is resolution's and
190// not some other gate's.
191func TestAmbiguousNameRendersPicker(t *testing.T) {
192 fx := newConsoleFixture(t)
193 c := newBrowser(t)
194 signUp(t, fx, c, "code-1")
195 csrf := sessionToken(t, fx, c)
196 idA, idB := testAccountID(), testAccountID()
197 sqidA, sqidB := accountSqid(idA), accountSqid(idB)
198 for _, x := range []struct {
199 id int64
200 sqid string
201 }{{idA, sqidA}, {idB, sqidB}} {
202 if err := fx.st.CreateAccount(x.id, x.sqid, "amadan", "paul@keymail.dev", time.Now()); err != nil {
203 t.Fatal(err)
204 }
205 }
206 st, _, body := get(t, c, fx.ts.URL+"/a/amadan/")
207 if st != http.StatusOK {
208 t.Fatalf("ambiguous GET = %d, want 200 picker", st)
209 }
210 if !strings.Contains(body, "/a/"+sqidA+"/") || !strings.Contains(body, "/a/"+sqidB+"/") {
211 t.Fatalf("picker must link both candidates by sqid:\n%s", body)
212 }
213 if st, _, _ := postForm(t, c, fx.ts.URL+"/a/amadan/invite",
214 url.Values{"csrf": {csrf}, "address": {"x@keymail.dev"}}); st != http.StatusNotFound {
215 t.Errorf("POST to ambiguous segment = %d, want 404", st)
216 }
217 // And the refused POST did nothing: no invite in either account.
218 for _, id := range []int64{idA, idB} {
219 if pending, _ := fx.st.PendingInvites(id); len(pending) != 0 {
220 t.Errorf("ambiguous POST still invited into account %d: %+v", id, pending)
221 }
222 }
223}
224
225// A name that equals another membership's SQID is ambiguous the same way —
226// symmetric resolution never silently prefers one space over the other.
227func TestNameSqidCollisionRendersPicker(t *testing.T) {
228 fx := newConsoleFixture(t)
229 c := newBrowser(t)
230 signUp(t, fx, c, "code-1")
231 idA := testAccountID()
232 sqidA := accountSqid(idA)
233 if err := fx.st.CreateAccount(idA, sqidA, "amadan", "paul@keymail.dev", time.Now()); err != nil {
234 t.Fatal(err)
235 }
236 idB := testAccountID()
237 if err := fx.st.CreateAccount(idB, accountSqid(idB), sqidA, "paul@keymail.dev", time.Now()); err != nil {
238 t.Fatal(err)
239 }
240 st, _, body := get(t, c, fx.ts.URL+"/a/"+sqidA+"/")
241 if st != http.StatusOK || !strings.Contains(body, "/a/"+accountSqid(idB)+"/") {
242 t.Fatalf("sqid-vs-name collision: status %d — want the picker listing both", st)
243 }
244}
245
246func TestLandingMintsPersonalAccountWhenNoMemberships(t *testing.T) {
247 fx := newConsoleFixture(t)
248 fx.setUser("newbie@keymail.dev")
249 c := newBrowser(t)
250 signUp(t, fx, c, "code-new")
251
252 st, loc, _ := get(t, c, fx.ts.URL+"/")
253 if st != http.StatusFound || !strings.HasPrefix(loc, "/a/") {
254 t.Fatalf("landing = %d -> %q, want 302 into an account", st, loc)
255 }
256 accts, err := fx.st.AccountsFor("newbie@keymail.dev")
257 if err != nil || len(accts) != 1 {
258 t.Fatalf("AccountsFor = %+v %v, want one minted account", accts, err)
259 }
260 if accts[0].Role != RoleOwner {
261 t.Errorf("minted account role = %q, want owner", accts[0].Role)
262 }
263 if loc != "/a/"+accts[0].Sqid+"/" {
264 t.Errorf("landed at %q, want /a/%s/", loc, accts[0].Sqid)
265 }
266 // The manifest is in the bucket, and its sqid is the frozen encoding.
267 if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(accts[0].Sqid)); err != nil {
268 t.Fatalf("no account manifest in the bucket: %v", err)
269 }
270 if accountSqid(accts[0].ID) != accts[0].Sqid {
271 t.Errorf("minted sqid %q is not Sqid(%d)", accts[0].Sqid, accts[0].ID)
272 }
273 // Landing again does NOT mint a second one.
274 get(t, c, fx.ts.URL+"/")
275 if accts, _ := fx.st.AccountsFor("newbie@keymail.dev"); len(accts) != 1 {
276 t.Errorf("a second landing minted another account: %+v", accts)
277 }
278}
279
280func TestOwnerOnlyActionsRefuseMembers(t *testing.T) {
281 fx := newConsoleFixture(t)
282 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
283 t.Fatal(err)
284 }
285 fx.setUser("sam@keymail.dev")
286 c := newBrowser(t)
287 signUp(t, fx, c, "code-sam")
288 csrf := sessionToken(t, fx, c)
289
290 // A member CAN deploy...
291 if st, _, _ := get(t, c, fx.u("/apps/kass")); st != http.StatusOK {
292 t.Fatalf("member app page = %d, want 200", st)
293 }
294 // ...but cannot invite or revoke: owner-only actions answer with the same
295 // 404 a non-member gets, so a member cannot even probe them.
296 st, _, _ := postForm(t, c, fx.u("/invite"),
297 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
298 if st != http.StatusNotFound {
299 t.Errorf("member invite = %d, want 404", st)
300 }
301 if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 {
302 t.Errorf("a member's refused invite still created one: %+v", pending)
303 }
304 st, _, _ = postForm(t, c, fx.u("/invite/revoke"), url.Values{"csrf": {csrf}, "token": {"whatever"}})
305 if st != http.StatusNotFound {
306 t.Errorf("member revoke = %d, want 404", st)
307 }
308}
309
310func TestInviteAcceptCreatesMembership(t *testing.T) {
311 fx := newConsoleFixture(t)
312 owner := newBrowser(t)
313 signUp(t, fx, owner, "code-owner")
314 csrf := sessionToken(t, fx, owner)
315
316 st, loc, _ := postForm(t, owner, fx.u("/invite"),
317 url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
318 if st != http.StatusSeeOther || loc != fx.path("/team") {
319 t.Fatalf("invite = %d -> %q", st, loc)
320 }
321 pending, _ := fx.st.PendingInvites(OpsAccountID)
322 if len(pending) != 1 || pending[0].Address != "sam@keymail.dev" {
323 t.Fatalf("PendingInvites = %+v", pending)
324 }
325 // The link went into sam's Keymail inbox by federation.
326 sent := fx.fk.fedBodies()
327 if len(sent) == 0 || !strings.Contains(strings.Join(sent, "\n"), "/invite/"+pending[0].Token) {
328 t.Fatalf("invite link not delivered through keymail: %v", sent)
329 }
330
331 // Sam signs in and opens the link: a page that asks, and changes nothing
332 // (#219 item 4 — a GET that joined let any page add a signed-in person to
333 // the inviter's account and switch them into it).
334 fx.setUser("sam@keymail.dev")
335 sam := newBrowser(t)
336 signUp(t, fx, sam, "code-sam")
337 inviteURL := fx.ts.URL + "/invite/" + pending[0].Token
338 st, _, body := get(t, sam, inviteURL)
339 if st != http.StatusOK || !strings.Contains(body, `action="/invite/`+pending[0].Token+`"`) {
340 t.Fatalf("invite link = %d, want 200 and a confirm form:\n%s", st, body)
341 }
342 if role, _ := fx.st.Role(OpsAccountID, "sam@keymail.dev"); role != "" {
343 t.Fatalf("opening the invite link joined the account (role %q)", role)
344 }
345 // A cross-site POST, without the session's CSRF token, is refused.
346 if st, _, _ := postForm(t, sam, inviteURL, url.Values{}); st != http.StatusForbidden {
347 t.Fatalf("accept without csrf = %d, want 403", st)
348 }
349 if role, _ := fx.st.Role(OpsAccountID, "sam@keymail.dev"); role != "" {
350 t.Fatalf("a POST without csrf joined the account (role %q)", role)
351 }
352 // The button.
353 samCSRF := sessionToken(t, fx, sam)
354 st, loc, _ = postForm(t, sam, inviteURL, url.Values{"csrf": {samCSRF}})
355 if st != http.StatusSeeOther || loc != fx.path("/") {
356 t.Fatalf("accept = %d -> %q, want 303 -> the account", st, loc)
357 }
358 if role, _ := fx.st.Role(OpsAccountID, "sam@keymail.dev"); role != RoleMember {
359 t.Fatalf("role after accept = %q, want member", role)
360 }
361 if st, _, _ := get(t, sam, fx.u("/")); st != http.StatusOK {
362 t.Errorf("accepted member cannot see the account: %d", st)
363 }
364 // Single use, on both the page and the button.
365 if st, _, _ := get(t, sam, inviteURL); st != http.StatusGone {
366 t.Errorf("used invite page = %d, want 410", st)
367 }
368 if st, _, _ := postForm(t, sam, inviteURL, url.Values{"csrf": {samCSRF}}); st == http.StatusSeeOther {
369 t.Error("invite link worked twice")
370 }
371}
372
373func TestSwitcherListsOnlyMyAccounts(t *testing.T) {
374 fx := newConsoleFixture(t)
375 // An account paul is not a member of.
376 otherID := testAccountID()
377 if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Someone Else", "sam@keymail.dev", time.Now()); err != nil {
378 t.Fatal(err)
379 }
380 mineID := testAccountID()
381 if err := fx.st.CreateAccount(mineID, accountSqid(mineID), "Mine", "paul@keymail.dev", time.Now()); err != nil {
382 t.Fatal(err)
383 }
384 c := newBrowser(t)
385 signUp(t, fx, c, "code-1")
386 _, _, body := get(t, c, fx.u("/"))
387 if !strings.Contains(body, "Mine") {
388 t.Error("switcher is missing an account the user belongs to")
389 }
390 if strings.Contains(body, "Someone Else") {
391 t.Errorf("switcher lists an account the user is not a member of:\n%s", body)
392 }
393 if !strings.Contains(body, "/a/"+accountSqid(mineID)+"/") {
394 t.Error("switcher has no link to the user's other account")
395 }
396}
397
398// The switcher's filter box appears once the list is long enough to need it,
399// and not before: a short list is quicker to read than to search.
400func TestSwitcherFilterOnlyForLongLists(t *testing.T) {
401 fx := newConsoleFixture(t)
402 c := newBrowser(t)
403 signUp(t, fx, c, "code-1")
404 accts, err := fx.st.AccountsFor("paul@keymail.dev")
405 if err != nil {
406 t.Fatal(err)
407 }
408 for n := len(accts); n < 7; n++ {
409 id := testAccountID()
410 if err := fx.st.CreateAccount(id, accountSqid(id), fmt.Sprintf("short%d", n), "paul@keymail.dev", time.Now()); err != nil {
411 t.Fatal(err)
412 }
413 }
414 _, _, body := get(t, c, fx.u("/"))
415 if strings.Contains(header(t, body), "switcher-find") {
416 t.Errorf("seven accounts rendered the filter box:\n%s", header(t, body))
417 }
418
419 id := testAccountID()
420 if err := fx.st.CreateAccount(id, accountSqid(id), "eighth", "paul@keymail.dev", time.Now()); err != nil {
421 t.Fatal(err)
422 }
423 _, _, body = get(t, c, fx.u("/"))
424 h := header(t, body)
425 if !strings.Contains(h, `class="switcher-find"`) || !strings.Contains(h, `class="switcher-none" hidden`) {
426 t.Errorf("eight accounts rendered no filter box:\n%s", h)
427 }
428}
429
430// header returns everything between <header> and </header> — the chrome, on
431// its own, so a switcher assertion cannot be satisfied by the page body
432// happening to contain an account name.
433func header(t *testing.T, body string) string {
434 t.Helper()
435 _, rest, ok := strings.Cut(body, "<header>")
436 if !ok {
437 t.Fatalf("no <header> in:\n%s", body)
438 }
439 head, _, ok := strings.Cut(rest, "</header>")
440 if !ok {
441 t.Fatalf("unterminated <header> in:\n%s", body)
442 }
443 return head
444}
445
446// The switcher IS the top-left of the header: a <details>/<summary>
447// disclosure naming the current account, in place of the old wordmark. The
448// disclosure element matters — it is what makes the dropdown keyboard
449// operable and screen-reader sane with no JavaScript at all.
450func TestHeaderSwitcherIsADropdownNamingTheCurrentAccount(t *testing.T) {
451 fx := newConsoleFixture(t)
452 otherID := testAccountID()
453 if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Kass", "paul@keymail.dev", time.Now()); err != nil {
454 t.Fatal(err)
455 }
456 c := signInPaul(t, fx)
457 _, _, body := get(t, c, fx.u("/"))
458 h := header(t, body)
459
460 if !strings.Contains(h, "<details class=\"switcher\"") {
461 t.Errorf("the header switcher is not a <details> disclosure:\n%s", h)
462 }
463 if !strings.Contains(h, "<summary") {
464 t.Errorf("the switcher has no <summary> trigger:\n%s", h)
465 }
466 // The trigger says what it is — the current account's name, not an icon.
467 summary, _, _ := strings.Cut(h, "</summary>")
468 if !strings.Contains(summary, "ops") {
469 t.Errorf("the switcher trigger does not name the current account:\n%s", summary)
470 }
471 // The wordmark it replaces is gone from an account page's header.
472 if strings.Contains(h, "CARLOS console") {
473 t.Errorf("the wordmark is still in the account page header:\n%s", h)
474 }
475 // The current account is marked as such, and the others are plain links.
476 if !strings.Contains(h, `aria-current="true"`) {
477 t.Errorf("the current account is not marked in the menu:\n%s", h)
478 }
479 if !strings.Contains(h, `href="/a/`+accountSqid(otherID)+`/"`) {
480 t.Errorf("the menu has no link to the user's other account:\n%s", h)
481 }
482 // And "New account" is a link to its own screen, not an inline form.
483 if !strings.Contains(h, `href="/accounts/new"`) {
484 t.Errorf("the menu has no link to the new-account screen:\n%s", h)
485 }
486 if strings.Contains(h, `action="/accounts/new"`) {
487 t.Errorf("the header still carries the inline new-account form:\n%s", h)
488 }
489}
490
491// A user with exactly one account still gets a coherent header: the
492// dropdown names it, marks it current, and still offers a new account.
493func TestSingleAccountHeaderIsCoherent(t *testing.T) {
494 fx := newConsoleFixture(t)
495 c := signInPaul(t, fx)
496 _, _, body := get(t, c, fx.u("/"))
497 h := header(t, body)
498
499 if accts, _ := fx.st.AccountsFor("paul@keymail.dev"); len(accts) != 1 {
500 t.Fatalf("fixture user has %d accounts, want exactly 1", len(accts))
501 }
502 if !strings.Contains(h, "<summary") || !strings.Contains(h, `aria-current="true"`) {
503 t.Errorf("single-account header is not a coherent switcher:\n%s", h)
504 }
505 if strings.Count(h, `aria-current="true"`) != 1 {
506 t.Errorf("single-account menu marks something other than one current account:\n%s", h)
507 }
508 if !strings.Contains(h, `href="/accounts/new"`) {
509 t.Errorf("single-account header lost the new-account link:\n%s", h)
510 }
511}
512
513// Keyless (read-only) deployments degrade MUTATIONS, never the chrome: the
514// switcher is navigation, and losing it would strand a read-only operator in
515// whichever account they happened to land in.
516func TestKeylessStillRendersTheSwitcher(t *testing.T) {
517 fx := newConsoleFixtureKeyed(t, false)
518 c := signInPaul(t, fx)
519 _, _, body := get(t, c, fx.u("/"))
520 h := header(t, body)
521 if !strings.Contains(h, "<details class=\"switcher\"") || !strings.Contains(h, "ops") {
522 t.Errorf("keyless mode dropped the account switcher:\n%s", h)
523 }
524 if !strings.Contains(h, `href="/accounts/new"`) {
525 t.Errorf("keyless mode dropped the new-account link:\n%s", h)
526 }
527}
528
529// Signed-out pages have no account to name, so they keep the wordmark —
530// a header with an empty dropdown in it would be worse than no dropdown.
531func TestSignedOutHeaderKeepsTheWordmark(t *testing.T) {
532 fx := newConsoleFixture(t)
533 _, _, body := get(t, newBrowser(t), fx.ts.URL+"/")
534 h := header(t, body)
535 if !strings.Contains(h, "CARLOS console") {
536 t.Errorf("signed-out header lost the wordmark:\n%s", h)
537 }
538 if strings.Contains(h, "<details class=\"switcher\"") {
539 t.Errorf("signed-out header renders an accountless switcher:\n%s", h)
540 }
541}
542
543// "New account" is a screen of its own at its own URL: gated on a session,
544// CSRF on its POST, and creating from it lands you in the new account.
545func TestNewAccountScreen(t *testing.T) {
546 fx := newConsoleFixture(t)
547
548 // Signed out, it is not a page you can see.
549 if st, loc, _ := get(t, newBrowser(t), fx.ts.URL+"/accounts/new"); st != http.StatusFound || loc != "/" {
550 t.Fatalf("signed-out /accounts/new = %d -> %q, want 302 -> /", st, loc)
551 }
552
553 c := signInPaul(t, fx)
554 st, _, body := get(t, c, fx.ts.URL+"/accounts/new")
555 if st != http.StatusOK {
556 t.Fatalf("/accounts/new = %d, want 200", st)
557 }
558 if !strings.Contains(body, `action="/accounts/new"`) || !strings.Contains(body, `name="csrf"`) {
559 t.Fatalf("the new-account screen has no CSRF-bearing form:\n%s", body)
560 }
561 // It is a page, not a header ornament: the chrome is still there.
562 if !strings.Contains(header(t, body), "<details class=\"switcher\"") {
563 t.Errorf("the new-account screen lost the header switcher:\n%s", body)
564 }
565
566 // The POST still refuses a forged CSRF token.
567 if st, _, _ := postForm(t, c, fx.ts.URL+"/accounts/new",
568 url.Values{"csrf": {"wrong"}, "name": {"Nope"}}); st != http.StatusForbidden {
569 t.Errorf("new account with a bad csrf = %d, want 403", st)
570 }
571
572 // Creating from the screen lands you in the account it made.
573 csrf := sessionToken(t, fx, c)
574 st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new",
575 url.Values{"csrf": {csrf}, "name": {"woodstar"}})
576 if st != http.StatusSeeOther {
577 t.Fatalf("create = %d -> %q, want 303", st, loc)
578 }
579 accts, _ := fx.st.AccountsFor("paul@keymail.dev")
580 var made *Account
581 for i := range accts {
582 if accts[i].Name == "woodstar" {
583 made = &accts[i]
584 }
585 }
586 if made == nil {
587 t.Fatalf("AccountsFor = %+v, want a woodstar", accts)
588 }
589 if loc != "/a/"+made.Sqid+"/" {
590 t.Fatalf("landed at %q, want /a/%s/", loc, made.Sqid)
591 }
592 // And the account you landed in is the one the header now names.
593 _, _, body = get(t, c, fx.ts.URL+loc)
594 if h := header(t, body); !strings.Contains(h, "woodstar") {
595 t.Errorf("the header does not name the account just created:\n%s", h)
596 }
597}
598
599// A nameless submission is a mistake to correct on the screen you are on,
600// not a bounce back to some other account.
601func TestNewAccountScreenRejectsAnEmptyNameInPlace(t *testing.T) {
602 fx := newConsoleFixture(t)
603 c := signInPaul(t, fx)
604 csrf := sessionToken(t, fx, c)
605
606 st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {" "}})
607 if st != http.StatusSeeOther || loc != "/accounts/new" {
608 t.Fatalf("empty name = %d -> %q, want 303 -> /accounts/new", st, loc)
609 }
610 if _, _, body := get(t, c, fx.ts.URL+"/accounts/new"); !strings.Contains(body, "Account names appear in URLs") {
611 t.Errorf("the screen does not say why it refused:\n%s", body)
612 }
613}
614
615// The three edges where a human types a name all refuse a non-slug; the
616// auto-mint path (address-named personal accounts) must NOT be validated —
617// a mint that can refuse strands a signed-in user.
618func TestNewAccountNameValidated(t *testing.T) {
619 fx := newConsoleFixture(t)
620 c := newBrowser(t)
621 signUp(t, fx, c, "code-1")
622 csrf := sessionToken(t, fx, c)
623 before, err := fx.st.AccountsFor("paul@keymail.dev")
624 if err != nil {
625 t.Fatal(err)
626 }
627 st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {"Paul's Stuff!"}})
628 if st != http.StatusSeeOther || loc != "/accounts/new" {
629 t.Fatalf("bad name = %d -> %q, want 303 back to the form", st, loc)
630 }
631 after, err := fx.st.AccountsFor("paul@keymail.dev")
632 if err != nil {
633 t.Fatal(err)
634 }
635 if len(after) != len(before) {
636 t.Fatal("a rejected name must not create an account")
637 }
638 if st, _, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {"amadan"}}); st != http.StatusSeeOther {
639 t.Fatalf("good name = %d, want 303 into the new account", st)
640 }
641}
642
643// Visiting an account page updates the MRU; visiting /accounts/new does not
644// — it is not an account you are "in", and moving the MRU there would send
645// the next sign-in somewhere the user never chose.
646func TestNewAccountScreenLeavesTheMRUAlone(t *testing.T) {
647 fx := newConsoleFixture(t)
648 c := signInPaul(t, fx)
649
650 id := testAccountID()
651 sqid := accountSqid(id)
652 if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil {
653 t.Fatal(err)
654 }
655 if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK {
656 t.Fatalf("second account home = %d", st)
657 }
658 if st, _, _ := get(t, c, fx.ts.URL+"/accounts/new"); st != http.StatusOK {
659 t.Fatalf("/accounts/new = %d", st)
660 }
661 if st, loc, _ := get(t, c, fx.ts.URL+"/"); st != http.StatusFound || loc != "/a/"+sqid+"/" {
662 t.Fatalf("landing = %d -> %q, want the MRU account /a/%s/", st, loc, sqid)
663 }
664}
665
666// The audit trail is per (account, app), not per app name. Two accounts can
667// both own a "blog"; sharing one activity stream would show each account the
668// other's actions — including the other account's member addresses, which is
669// a membership leak dressed up as a convenience.
670func TestAuditIsScopedToTheAccount(t *testing.T) {
671 fx := newConsoleFixture(t)
672 now := time.Now()
673 other := testAccountID()
674 if err := fx.st.CreateAccount(other, accountSqid(other), "Other", "sam@keymail.dev", now); err != nil {
675 t.Fatal(err)
676 }
677 if _, err := fx.st.CreateAppIn(other, "kass", "sam@keymail.dev", now); err != nil {
678 t.Fatal(err)
679 }
680
681 if err := fx.st.RecordEvent(OpsAccountID, "kass", "paul@keymail.dev", "promote", "promoted v1 to stable", now); err != nil {
682 t.Fatal(err)
683 }
684 if err := fx.st.RecordEvent(other, "kass", "sam@keymail.dev", "promote", "promoted v9 to stable", now); err != nil {
685 t.Fatal(err)
686 }
687
688 ours, err := fx.st.Events(OpsAccountID, "kass", 10)
689 if err != nil {
690 t.Fatal(err)
691 }
692 if len(ours) != 1 || ours[0].Address != "paul@keymail.dev" {
693 t.Fatalf("Events(ops, kass) = %+v, want only our own", ours)
694 }
695 theirs, _ := fx.st.Events(other, "kass", 10)
696 if len(theirs) != 1 || theirs[0].Address != "sam@keymail.dev" {
697 t.Fatalf("Events(other, kass) = %+v", theirs)
698 }
699
700 // And through the page, which is where the leak would actually be seen.
701 c := signInPaul(t, fx)
702 _, _, body := get(t, c, fx.u("/apps/kass/activity"))
703 if !strings.Contains(body, "promoted v1 to stable") {
704 t.Error("the account's own activity is missing from its activity tab")
705 }
706 if strings.Contains(body, "sam@keymail.dev") || strings.Contains(body, "promoted v9 to stable") {
707 t.Errorf("another account's activity leaked onto its activity tab:\n%s", body)
708 }
709}
710
711// The silent blackhole: `carlos accounts migrate` re-stamps the ROUTES, and
712// if `console migrate-app` is then skipped, the console promotes into one
713// account's keys while every host reads another's. "Promoted v2 to stable"
714// flashes green and nothing deploys, forever, with no error anywhere.
715//
716// So the app page says so, and points at the fix: the overview and releases
717// tabs both link to Settings, which is where the repair itself now lives
718// (appsettings.go) — a button when the signed-in owner can reach the target
719// account, the operator command as a fallback when they cannot.
720func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
721 fx := newConsoleFixture(t)
722 c := signInPaul(t, fx)
723
724 // Agreeing (the fixture's routes carry no account, which reads as ops):
725 // no warning, or the warning is worthless.
726 _, _, body := get(t, c, fx.u("/apps/kass"))
727 if strings.Contains(body, "not deploying") {
728 t.Fatalf("warned about an app whose accounts agree:\n%s", body)
729 }
730
731 // Now the routes move to another account and the console row is left
732 // behind — exactly the half-migrated state.
733 reg, err := registry.Open(fx.regPath)
734 if err != nil {
735 t.Fatal(err)
736 }
737 moved := testAccountID()
738 sqid := accountSqid(moved)
739 if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil {
740 t.Fatal(err)
741 }
742 if err := fx.st.AddMember(moved, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
743 t.Fatal(err)
744 }
745 for _, host := range []string{"kass.carlosapp.dev", "docs.kass.carlosapp.dev"} {
746 if err := reg.SetAccount(host, sqid); err != nil {
747 t.Fatal(err)
748 }
749 }
750 reg.Close()
751
752 _, _, body = get(t, c, fx.u("/apps/kass"))
753 if !strings.Contains(body, `/apps/kass/account-review`) {
754 t.Errorf("no warning on a half-migrated app:\n%s", body)
755 }
756
757 // The promote/rollback/canary forms live on the releases tab, not the
758 // overview — a deep link there must not promote blind. Same warning,
759 // same fix, on the page that actually hosts the mutations.
760 _, _, body = get(t, c, fx.u("/apps/kass/releases"))
761 if !strings.Contains(body, `/apps/kass/account-review`) {
762 t.Errorf("no warning on the releases tab of a half-migrated app:\n%s", body)
763 }
764
765 // Settings itself holds the actual repair. paul owns the target account
766 // here (CreateAccount above makes its creator the owner), so he can reach
767 // it — the button, not the operator-command fallback, is what he's owed.
768 _, _, body = get(t, c, fx.u("/apps/kass/settings"))
769 if !strings.Contains(body, "Move to "+sqid) {
770 t.Errorf("Settings does not offer the move-to-target button for an owner who can reach it:\n%s", body)
771 }
772 if !strings.Contains(body, `action="`+fx.path("/apps/kass/move")+`"`) {
773 t.Errorf("Settings' move button does not post to the move endpoint:\n%s", body)
774 }
775}
776
777// Creating an account from the console: minted id, bucket manifest, owner
778// membership, and you land in it.
779func TestCreateAccountFromConsole(t *testing.T) {
780 fx := newConsoleFixture(t)
781 c := newBrowser(t)
782 signUp(t, fx, c, "code-1")
783 csrf := sessionToken(t, fx, c)
784
785 st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new",
786 url.Values{"csrf": {csrf}, "name": {"kass"}})
787 if st != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
788 t.Fatalf("create account = %d -> %q", st, loc)
789 }
790 accts, _ := fx.st.AccountsFor("paul@keymail.dev")
791 var made *Account
792 for i := range accts {
793 if accts[i].Name == "kass" {
794 made = &accts[i]
795 }
796 }
797 if made == nil || made.Role != RoleOwner {
798 t.Fatalf("AccountsFor = %+v, want an owned kass", accts)
799 }
800 if _, _, err := fx.rel.Get(t.Context(), AccountManifestKey(made.Sqid)); err != nil {
801 t.Errorf("no manifest for the new account: %v", err)
802 }
803 if loc != "/a/"+made.Sqid+"/" {
804 t.Errorf("landed at %q, want the new account", loc)
805 }
806}
807
808// App names are unique per ACCOUNT: the same name in two accounts is two
809// different apps, with two different bucket prefixes.
810func TestSameAppNameInTwoAccounts(t *testing.T) {
811 fx := newConsoleFixture(t)
812 c := newBrowser(t)
813 signUp(t, fx, c, "code-1")
814 csrf := sessionToken(t, fx, c)
815
816 id := testAccountID()
817 sqid := accountSqid(id)
818 if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil {
819 t.Fatal(err)
820 }
821 for _, base := range []string{fx.path(""), "/a/" + sqid} {
822 st, _, _ := postForm(t, c, fx.ts.URL+base+"/apps/new",
823 url.Values{"csrf": {csrf}, "name": {"blog"}})
824 if st != http.StatusSeeOther {
825 t.Fatalf("claim blog under %s = %d", base, st)
826 }
827 }
828 for _, acct := range []int64{OpsAccountID, id} {
829 apps, err := fx.st.AppsIn(acct)
830 if err != nil || len(apps) == 0 {
831 t.Fatalf("AppsIn(%d) = %v %v", acct, apps, err)
832 }
833 found := false
834 for _, a := range apps {
835 if a == "blog" {
836 found = true
837 }
838 }
839 if !found {
840 t.Errorf("account %d has no blog: %v", acct, apps)
841 }
842 }
843}
844
845// The account nav grows Apps · Team · Settings, account-scoped and marking
846// whichever section the page sits in, and the signed-in address becomes the
847// door to personal settings (registered later this branch).
848func TestAccountNavHasSections(t *testing.T) {
849 fx := newConsoleFixture(t)
850 c := signInPaul(t, fx)
851 _, home, _ := get(t, c, fx.ts.URL+"/")
852 nav := strings.TrimSuffix(home, "/")
853 _, _, body := get(t, c, fx.ts.URL+home)
854 h := header(t, body)
855 for _, want := range []string{
856 `href="` + nav + `/"`, `href="` + nav + `/team"`, `href="` + nav + `/settings"`,
857 `href="/settings"`, // the address is the door to personal settings
858 } {
859 if !strings.Contains(h, want) {
860 t.Fatalf("header missing %q in:\n%s", want, h)
861 }
862 }
863 if strings.Count(h, `aria-current="page"`) != 1 {
864 t.Fatalf("want exactly one active nav section on the apps page, got:\n%s", h)
865 }
866 _, _, team := get(t, c, fx.ts.URL+nav+"/team")
867 if !strings.Contains(header(t, team), `/team" aria-current="page"`) {
868 t.Fatal("team page does not mark Team active")
869 }
870}
871