| index 28bda9805..93b4bf4a2 100644 |
| --- a/internal/console/fleets_test.go |
| +++ b/internal/console/fleets_test.go |
| @@ -953,3 +953,216 @@ func TestFleetPageRotateShowsTokenOnceWithWarning(t *testing.T) { |
| t.Fatalf("no 64-hex token in the rotate response hashes to the stored TokenHash %s:\n%s", boxes[0].TokenHash, body) |
| } |
| } |
| + |
| +// TestFleetPageTagsChipsRender: a box carrying tags renders each one as its |
| +// own chip, both on the owner's page and a member's — chips are read-only |
| +// display, not a mutation surface, so they belong to the same body a member |
| +// already sees the rest of the row in (TestFleetPageOwnerControlsMemberReadOnly). |
| +// An untagged box renders the em-dash placeholder instead of an empty cell. |
| +func TestFleetPageTagsChipsRender(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-tags-chips") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + for _, label := range []string{"pi-1", "pi-2"} { |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": label}, token); st != http.StatusOK { |
| + t.Fatalf("add-box %s = %d %v", label, st, body) |
| + } |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/tags", |
| + map[string]string{"tags": "office,free"}, token); st != http.StatusOK { |
| + t.Fatalf("tag-box = %d %v", st, body) |
| + } |
| + |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-tags-chips-browser") |
| + st, _, body := get(t, c, fx.u("/fleets/acme")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleets/acme = %d, want 200:\n%s", st, body) |
| + } |
| + for _, want := range []string{`<span class="chip">office</span>`, `<span class="chip">free</span>`} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("tagged box missing chip %q in:\n%s", want, body) |
| + } |
| + } |
| + // pi-2 carries no tags: an em-dash, never an empty cell nor pi-1's chips |
| + // leaking onto its row. |
| + if !strings.Contains(body, "pi-2") { |
| + t.Fatalf("untagged box pi-2 missing from the page:\n%s", body) |
| + } |
| + |
| + // A member sees the same chips (read-only display, no gate on it) but |
| + // none of the tag form's own markup. |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + member := newBrowser(t) |
| + signUp(t, fx, member, "code-fleetpage-tags-chips-member") |
| + memberStatus, _, memberBody := get(t, member, fx.u("/fleets/acme")) |
| + if memberStatus != http.StatusOK { |
| + t.Fatalf("member GET /fleets/acme = %d, want 200:\n%s", memberStatus, memberBody) |
| + } |
| + for _, want := range []string{`<span class="chip">office</span>`, `<span class="chip">free</span>`} { |
| + if !strings.Contains(memberBody, want) { |
| + t.Fatalf("member view missing chip %q in:\n%s", want, memberBody) |
| + } |
| + } |
| + if strings.Contains(memberBody, "/fleets/acme/tags") { |
| + t.Fatalf("member fleet page renders the tag mutation form it must not have:\n%s", memberBody) |
| + } |
| +} |
| + |
| +// TestFleetPageTagBoxOwnerUpdatesAndRedirects: the owner's Tag form performs |
| +// a full-replace write (same as handleAPIFleetTagBox) and redirects back to |
| +// the fleet page with an ordinary ok flash — no secret involved, unlike |
| +// rotate, so there is no reason for this to render directly the way |
| +// handleFleetRotateBoxPage's response does. |
| +func TestFleetPageTagBoxOwnerUpdatesAndRedirects(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-tags-owner") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-tags-owner-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/fleets/acme/tags"), |
| + url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"office,free"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/fleets/acme") { |
| + t.Fatalf("tag-box = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/acme")) |
| + } |
| + |
| + reg := openTestReg(t, fx) |
| + boxes, err := reg.ListFleetBoxes("acme") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 || boxes[0].Tags != "office,free" { |
| + t.Fatalf("registry boxes = %+v, want tags office,free", boxes) |
| + } |
| + |
| + _, _, body := get(t, c, fx.u("/fleets/acme")) |
| + if !strings.Contains(body, "Tagged acme/pi-1.") { |
| + t.Fatalf("no tag flash after redirect:\n%s", body) |
| + } |
| + if !strings.Contains(body, `<span class="chip">office</span>`) { |
| + t.Fatalf("new tags did not render as chips after the redirect:\n%s", body) |
| + } |
| +} |
| + |
| +// TestFleetPageTagBoxMemberGetsSameRefusalAsRotate: a plain member posting |
| +// the tag form gets the exact same refusal ownerFleet already gives rotate |
| +// and detach — the gate is s.owner (role != RoleOwner -> 404, matching a |
| +// non-member's genuinely-unrouted 404), never a distinguishable 403. |
| +func TestFleetPageTagBoxMemberGetsSameRefusalAsRotate(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-tags-member") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + 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-fleetpage-tags-member-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + tagStatus, _, tagBody := postForm(t, c, fx.u("/fleets/acme/tags"), |
| + url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"office"}}) |
| + rotStatus, _, rotBody := postForm(t, c, fx.u("/fleets/acme/rotate"), |
| + url.Values{"csrf": {csrf}, "label": {"pi-1"}}) |
| + if tagStatus != http.StatusNotFound { |
| + t.Fatalf("member tag-box = %d, want 404", tagStatus) |
| + } |
| + if tagStatus != rotStatus || tagBody != rotBody { |
| + t.Fatalf("member tag-box refusal (%d %q) not byte-identical to rotate's (%d %q)", |
| + tagStatus, tagBody, rotStatus, rotBody) |
| + } |
| + |
| + reg := openTestReg(t, fx) |
| + boxes, err := reg.ListFleetBoxes("acme") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if boxes[0].Tags != "" { |
| + t.Fatalf("member's refused tag POST still wrote tags: %+v", boxes) |
| + } |
| +} |
| + |
| +// TestFleetPageTagBoxWithoutCSRFForbidden: the tag form is CSRF-gated like |
| +// every other mutation on this console — the same 403 rotate's CSRF check |
| +// gives (TestFleetPageRotateWithoutCSRFForbidden). |
| +func TestFleetPageTagBoxWithoutCSRFForbidden(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-tags-csrf") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-tags-csrf-browser") |
| + st, _, _ := postForm(t, c, fx.u("/fleets/acme/tags"), url.Values{"csrf": {"wrong"}, "label": {"pi-1"}, "tags": {"office"}}) |
| + if st != http.StatusForbidden { |
| + t.Fatalf("tag-box with a bad csrf = %d, want 403", st) |
| + } |
| +} |
| + |
| +// TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten: reject, don't |
| +// sanitize — invalid grammar (uppercase, here) never reaches SetFleetBoxTags, |
| +// the same "checked before the write, not after" posture |
| +// handleAPIFleetTagBox already takes, and the flash copy matches Task 2's |
| +// API error text verbatim. |
| +func TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-tags-badgrammar") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/tags", |
| + map[string]string{"tags": "office"}, token); st != http.StatusOK { |
| + t.Fatalf("seed tag-box = %d %v", st, body) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-tags-badgrammar-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/fleets/acme/tags"), |
| + url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"Office"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/fleets/acme") { |
| + t.Fatalf("bad-grammar tag-box = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/acme")) |
| + } |
| + |
| + _, _, body := get(t, c, fx.u("/fleets/acme")) |
| + if !strings.Contains(body, "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") { |
| + t.Fatalf("bad-grammar tag-box did not flash the expected error:\n%s", body) |
| + } |
| + |
| + reg := openTestReg(t, fx) |
| + boxes, err := reg.ListFleetBoxes("acme") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if boxes[0].Tags != "office" { |
| + t.Fatalf("registry tags after rejected write = %q, want unchanged office", boxes[0].Tags) |
| + } |
| +} |