Skip to content
quarry Artifact [d47c2ce593]
Login

Artifact [d47c2ce593]

Artifact d47c2ce59347022b8e2843f4c820d4144bae71fe4bbc589d4a6e216268aabaa3:


package web

import (
	"bytes"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sort"
	"strings"
	"sync"
	"testing"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/evt"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// memCaps is a caps plane in a map: stored strings are the effective
// set (no categories), and the guard refuses self-demotion from a.
type memCaps struct {
	stored map[string]string
	cats   map[string]string // "repo/category" → string; fleet under ""
}

func (m *memCaps) cat(repo, c string) string {
	if v, ok := m.cats[repo+"/"+c]; ok {
		return v
	}
	if v, ok := m.cats["/"+c]; ok {
		return v
	}
	return map[string]string{"nobody": "gjorzh2", "anonymous": "mnc3C"}[c]
}

func (m *memCaps) CapsOf(acct, _ string) CapSet { return oltp.CapSet(m.stored[acct]) }
func (m *memCaps) CapHolders(string) ([]CapHolder, error) {
	names := make([]string, 0, len(m.stored))
	for n := range m.stored {
		names = append(names, n)
	}
	sort.Strings(names)
	var out []CapHolder
	for _, n := range names {
		out = append(out, CapHolder{Account: n, Caps: m.stored[n]})
	}
	return out, nil
}
func (m *memCaps) CategoryCaps(repo, cat string) string { return m.cat(repo, cat) }
func (m *memCaps) SetCaps(acct, _, caps string) error   { m.stored[acct] = caps; return nil }
func (m *memCaps) SetCategoryCaps(repo, cat, caps string) error {
	if m.cats == nil {
		m.cats = map[string]string{}
	}
	m.cats[repo+"/"+cat] = caps
	return nil
}
func (m *memCaps) EffectiveWith(_, _, own string) CapSet { return oltp.CapSet(own) }
func (m *memCaps) GuardCaps(actor, acct, repo, caps string) (string, error) {
	if actor == acct && oltp.CapSet(m.stored[acct]).HasAny("a") && !oltp.CapSet(caps).HasAny("a") {
		return "", oltp.Refusal{Reason: "that would remove your own admin on " + repo}
	}
	return "", nil
}

func peopleServer(t *testing.T, plane *memCaps) *httptest.Server {
	t.Helper()
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:  api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}}),
		Roster: capsRoster{plane}}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
		WithCaps(plane)}))
	t.Cleanup(srv.Close)
	return srv
}

func postAsDan(t *testing.T, srv *httptest.Server, path string, form url.Values) *http.Response {
	t.Helper()
	req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	client := srv.Client()
	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
	resp, err := client.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	return resp
}

// RFC 0008 §IA: the People section reads each string as a role and a
// sentence; the raw per-account inputs are gone from Settings.
func TestSettingsReadsPeopleAsRolesAndSentences(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Maintain.Caps() + "e", "eve": ""}}
	srv := peopleServer(t, plane)
	body := asDan(t, srv, "/r/alpha/settings")
	for _, want := range []string{`href="/r/alpha/people/bob"`, `<span class="chip role">Maintainer</span>`, `chip plus">+ `, "bob can "} {
		if !strings.Contains(body, want) {
			t.Fatalf("settings lacks %q", want)
		}
	}
	if strings.Contains(body, `name="acct-bob"`) {
		t.Fatal("raw per-account inputs are still on Settings")
	}
	if strings.Contains(body, `href="/r/alpha/people/eve"`) {
		t.Fatal("eve holds nothing here and should not be listed")
	}
}

// The editor: a role radio, the abilities as boxes, the string under a
// disclosure. Saving a role change writes the preset and says so.
func TestPersonEditorSavesARoleChange(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
	srv := peopleServer(t, plane)
	body := asDan(t, srv, "/r/alpha/people/bob")
	for _, want := range []string{`value="Developer" checked`, `name="ab"`, `name="raw"`, `name="was-role" value="Developer"`} {
		if !strings.Contains(body, want) {
			t.Fatalf("editor lacks %q", want)
		}
	}
	resp := postAsDan(t, srv, "/r/alpha/people/bob", url.Values{
		"role": {"Maintainer"}, "was-role": {"Developer"},
		"raw": {oltp.Write.Caps()}, "was-raw": {oltp.Write.Caps()},
		"ab": {"u"}, "was-ab": {"u"}})
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("status %d", resp.StatusCode)
	}
	if plane.stored["bob"] != oltp.Maintain.Caps() {
		t.Fatalf("stored %q", plane.stored["bob"])
	}
	loc := resp.Header.Get("Location")
	if !strings.Contains(loc, "Developer+%E2%86%92+Maintainer") {
		t.Fatalf("redirect does not say what changed: %s", loc)
	}
	if got := asDan(t, srv, loc); !strings.Contains(got, "Saved. Developer → Maintainer") {
		t.Fatal("the change line is not shown after save")
	}
}

// Bulletproofing: the guard's refusal is shown where the save was, and
// nothing is written.
func TestPersonEditorShowsARefusalInsteadOfSaving(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a"}}
	srv := peopleServer(t, plane)
	resp := postAsDan(t, srv, "/r/alpha/people/dan", url.Values{
		"role": {"Developer"}, "was-role": {"Admin"}, "raw": {"a"}, "was-raw": {"a"}})
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("status %d", resp.StatusCode)
	}
	b, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(b), "Not saved: that would remove your own admin") {
		t.Fatal("refusal not shown")
	}
	if plane.stored["dan"] != "a" {
		t.Fatalf("saved anyway: %q", plane.stored["dan"])
	}
}

// Adding from the roster form writes the role's string and lands on
// the person's page; an unknown login is refused in words.
func TestRosterAddsAPersonByRole(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "eve": ""}}
	srv := peopleServer(t, plane)
	resp := postAsDan(t, srv, "/r/alpha/people", url.Values{"name": {"eve"}, "role": {"Reader"}})
	if resp.StatusCode != http.StatusSeeOther || !strings.HasPrefix(resp.Header.Get("Location"), "/r/alpha/people/eve?changed=added+as+Reader") {
		t.Fatalf("%d %s", resp.StatusCode, resp.Header.Get("Location"))
	}
	if plane.stored["eve"] != oltp.Read.Caps() {
		t.Fatalf("stored %q", plane.stored["eve"])
	}
	resp = postAsDan(t, srv, "/r/alpha/people", url.Values{"name": {"ghost"}, "role": {"Reader"}})
	if loc := resp.Header.Get("Location"); !strings.Contains(loc, "no+account+named+ghost") {
		t.Fatalf("unknown login: %s", loc)
	}
}

// nextCaps: the string wins when edited; a new role replaces; boxes
// are the string; untouched boxes keep the stored bytes.
func TestNextCapsReadsTheFormInOrder(t *testing.T) {
	dev := oltp.Write.Caps()
	base := url.Values{"role": {"Developer"}, "was-role": {"Developer"}, "raw": {dev}, "was-raw": {dev}, "was-ab": {dev}, "ab": strings.Split(dev, "")}
	if got := nextCaps(base, dev); got != dev {
		t.Fatalf("untouched form wrote %q", got)
	}
	f := url.Values{}
	for k, v := range base {
		f[k] = v
	}
	f.Set("raw", "iy")
	if got := nextCaps(f, dev); got != "iy" {
		t.Fatalf("edited string lost: %q", got)
	}
	f = url.Values{}
	for k, v := range base {
		f[k] = v
	}
	f["ab"] = append(f["ab"], "5") // moderate the forum
	if got := nextCaps(f, dev); oltp.Closure(got) != oltp.Closure(dev+"5") || strings.Contains(got, "3") {
		t.Fatalf("ticked box: %q", got)
	}
	f.Set("revoke", "1")
	if got := nextCaps(f, dev); got != "" {
		t.Fatalf("revoke: %q", got)
	}
}

// RFC 0008 §Visibility: Settings asks who can see this first, as
// presets; a preset writes the two category strings.
func TestVisibilityPresetsWriteTheCategoryStrings(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a"}}
	srv := peopleServer(t, plane)
	body := asDan(t, srv, "/r/alpha/settings")
	if !strings.Contains(body, `value="Public project" checked`) || strings.Contains(body, `name="private"`) {
		t.Fatal("settings should open on the visibility presets, without the old checkbox")
	}
	resp := postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Members only"}})
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("status %d", resp.StatusCode)
	}
	if plane.cat("alpha", "nobody") != "" || plane.cat("alpha", "anonymous") != "gjorzh2mnc3C8" {
		t.Fatalf("strings after Members only: %q / %q", plane.cat("alpha", "nobody"), plane.cat("alpha", "anonymous"))
	}
	if body := asDan(t, srv, "/r/alpha/settings"); !strings.Contains(body, `value="Members only" checked`) {
		t.Fatal("the preset does not read back")
	}
	// Hand-typed strings that match no preset read as Custom, decoded.
	plane.cats["alpha/nobody"], plane.cats["alpha/anonymous"] = "o", "o"
	if body := asDan(t, srv, "/r/alpha/settings"); !strings.Contains(body, `value="Custom" checked`) || !strings.Contains(body, "visitors can ") {
		t.Fatal("Custom is not shown decoded")
	}
}

// Private is a wizard: whoever reaches the repository only through
// the categories is named and offered a role before the strings blank.
func TestTakingPrivateOffersRolesToWhoWouldLoseAccess(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "eve": "", "bob": oltp.Write.Caps()}}
	srv := peopleServer(t, plane)
	resp := postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Private"}})
	if loc := resp.Header.Get("Location"); loc != "/r/alpha/settings/private" {
		t.Fatalf("went to %s, not the wizard", loc)
	}
	if plane.cat("alpha", "nobody") != "gjorzh2" {
		t.Fatal("the strings blanked before anyone was asked")
	}
	body := asDan(t, srv, "/r/alpha/settings/private")
	if !strings.Contains(body, `name="role-eve"`) || strings.Contains(body, `name="role-bob"`) {
		t.Fatal("the wizard should name eve and not bob")
	}
	resp = postAsDan(t, srv, "/r/alpha/settings/private", url.Values{"confirm": {"1"}, "role-eve": {"Reader"}})
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("status %d", resp.StatusCode)
	}
	if plane.stored["eve"] != oltp.Read.Caps() || plane.cat("alpha", "nobody") != "" || plane.cat("alpha", "anonymous") != "" {
		t.Fatalf("after confirm: eve=%q nobody=%q anon=%q", plane.stored["eve"], plane.cat("alpha", "nobody"), plane.cat("alpha", "anonymous"))
	}
	// With nobody to lose access, Private is one step.
	plane.stored["eve"] = oltp.Read.Caps()
	plane.cats = map[string]string{}
	resp = postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Private"}})
	if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/r/alpha/settings") {
		t.Fatalf("went to %s", loc)
	}
	// The confirmation travels as a one-shot flash, not in the URL: a
	// query parameter stays in the address bar and a reload replays it.
	// Asserting the cookie is asserting the contract; the old check
	// pinned the spelling of a mechanism instead.
	if got := strings.Join(resp.Header.Values("Set-Cookie"), " "); !strings.Contains(got, "q_flash=visibility") {
		t.Errorf("visibility saved without a flash: %q", got)
	}
}

// RFC 0008 §IA: Settings reads top to bottom in the order of the
// questions — who can see this, people, branches, repository,
// integrations, advanced, danger — and the raw category strings live
// under the Advanced disclosure with a decoder beside each.
func TestSettingsSectionsAreInIAOrder(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a"}}
	srv := peopleServer(t, plane)
	body := asDan(t, srv, "/r/alpha/settings")
	// protected and integrations are conditional on planes this fixture
	// does not wire; the rest prove the top-to-bottom order.
	order := []string{
		`id="visibility"`, `id="people"`, `id="about"`, `id="access"`, `id="danger"`,
	}
	at := -1
	for _, marker := range order {
		i := strings.Index(body, marker)
		if i < 0 {
			t.Fatalf("section %q missing", marker)
		}
		if i < at {
			t.Fatalf("section %q is out of IA order", marker)
		}
		at = i
	}
	// The raw strings are Advanced now, decoded in place.
	if !strings.Contains(body, "<summary>Capability letters") {
		t.Fatal("category strings are not under the Advanced disclosure")
	}
	if !strings.Contains(body, "— can ") {
		t.Fatal("no decoder beside the category inputs")
	}
}

// RFC 0008 rung 7: the live preview recomputes the whole region from
// the form and morphs it — the sentence, the checkboxes and the
// string are one signal — without writing anything. Ticking a box the
// string does not yet grant comes back with the string widened.
func TestPreviewFragRecomputesWithoutWriting(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
	srv := peopleServer(t, plane)
	// The page arms the controls when the plane can answer what-if.
	page := asDan(t, srv, "/r/alpha/people/bob")
	if !strings.Contains(page, "preview.frag") || !strings.Contains(page, `id="person-live"`) {
		t.Fatal("the editor is not armed for live preview")
	}
	// Tick "moderate the forum" (5): the string was Developer's.
	form := url.Values{"role": {"Developer"}, "was-role": {"Developer"},
		"raw": {oltp.Write.Caps()}, "was-raw": {oltp.Write.Caps()},
		"was-ab": {oltp.Write.Caps()}}
	for _, l := range strings.Split(oltp.Write.Caps(), "") {
		form.Add("ab", l)
	}
	form.Add("ab", "5")
	req, _ := http.NewRequest("GET", srv.URL+"/r/alpha/people/bob/preview.frag?"+form.Encode(), nil)
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
		t.Fatalf("content-type %q", ct)
	}
	b, _ := io.ReadAll(resp.Body)
	body := string(b)
	if !strings.Contains(body, "datastar-patch-elements") || !strings.Contains(body, `id="person-live"`) {
		t.Fatal("not a person-live morph")
	}
	// The widened string is echoed back and the change line is shown;
	// nothing was written.
	if !strings.Contains(body, "moderate the forum") {
		t.Fatal("the widened ability is not named in the preview")
	}
	i := strings.Index(body, `id="person-raw"`)
	if i < 0 || !strings.Contains(body[i:i+120], "5") {
		t.Fatal("the string input does not reflect the ticked ability")
	}
	if plane.stored["bob"] != oltp.Write.Caps() {
		t.Fatalf("preview wrote to the store: %q", plane.stored["bob"])
	}
}

// A candidate the guard refuses is previewed with the reason, still
// without writing.
func TestPreviewFragShowsARefusal(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a"}}
	srv := peopleServer(t, plane)
	form := url.Values{"role": {"Developer"}, "was-role": {"Admin"}, "raw": {"a"}, "was-raw": {"a"}}
	req, _ := http.NewRequest("GET", srv.URL+"/r/alpha/people/dan/preview.frag?"+form.Encode(), nil)
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(b), "remove your own admin") {
		t.Fatal("refusal not previewed")
	}
	if plane.stored["dan"] != "a" {
		t.Fatal("preview wrote")
	}
}

// RFC 0008 rung 8: the Advanced section carries the fossil settings and
// configuration surfaces. On a store with no working tree they render
// the honest blocked-on-upstream note (zit#153), never a fake editor.
func TestFossilSettingsShowTheUpstreamNote(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a"}}
	srv := peopleServer(t, plane) // its repo has an Index but no Zit client
	body := asDan(t, srv, "/r/alpha/settings")
	if !strings.Contains(body, "Fossil settings") || !strings.Contains(body, "Configuration areas") {
		t.Fatal("the Advanced fossil surfaces are missing")
	}
	if !strings.Contains(body, "no store to read settings") {
		t.Fatalf("the no-store fallback note is not shown")
	}
	// No editable settings table when the store cannot answer.
	if strings.Contains(body, `name="set-`) {
		t.Fatal("a settings editor was drawn without a store to back it")
	}
}

// Audit follow-up (ticket ed5bdd89c39a): the raw acct- fields left the
// Settings form in rung 6, but the endpoint still accepts them — so a
// stale page or a hand-built POST meets the same guard the editor has.
func TestRawCapsEndpointHonorsTheGuard(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
	srv := peopleServer(t, plane)
	resp := postAsDan(t, srv, "/r/alpha/settings/caps", url.Values{"acct-dan": {"uv"}})
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("status %d", resp.StatusCode)
	}
	if loc := resp.Header.Get("Location"); !strings.Contains(loc, "e=") || !strings.Contains(loc, "your+own+admin") {
		t.Fatalf("refusal not surfaced: %s", loc)
	}
	if plane.stored["dan"] != "a" {
		t.Fatalf("saved through the guard: %q", plane.stored["dan"])
	}
	// A permitted raw save still goes through.
	resp = postAsDan(t, srv, "/r/alpha/settings/caps", url.Values{"acct-bob": {"u"}})
	if resp.StatusCode != http.StatusSeeOther || plane.stored["bob"] != "u" {
		t.Fatalf("permitted save refused: %d %q", resp.StatusCode, plane.stored["bob"])
	}
}

// The page describes a NAME. Two repositories using the same name is
// not evidence of one person — fossil's U card is a bare string — so
// the page must not sum them into a single contributor, and must say
// so when the name is shared.
func TestPersonPageDoesNotInventAContributor(t *testing.T) {
	shared := personRow{User: "dan", Events: 7800,
		Appearances: []personAppearance{
			{Repo: "sqlite", Events: 7002, First: "2007-07-28T00:00:00Z", Last: "2026-08-30T00:00:00Z"},
			{Repo: "quarry", Events: 5, First: "2026-08-29T00:00:00Z", Last: "2026-08-31T00:00:00Z"},
		}}
	var buf bytes.Buffer
	if err := personPage.Execute(&buf, pageData{Title: "dan", HideH1: true, Person: shared}); err != nil {
		t.Fatal(err)
	}
	html := buf.String()
	if strings.Contains(html, `title="7800"`) || strings.Contains(html, "7.8K") {
		t.Error("the page still sums a shared name into one contributor")
	}
	if !strings.Contains(html, "These may be different people") {
		t.Error("a shared name carries no warning that it may be several people")
	}
	// Each repository's OWN figure. count renders a titled span, so the
	// exact number is asserted through the title the forge puts there.
	for _, want := range []string{"sqlite", "quarry", `title="7002"`, `title="5"`} {
		if !strings.Contains(html, want) {
			t.Errorf("the per-repository breakdown is missing %q", want)
		}
	}

	// A name in ONE repository is not ambiguous, and should not be
	// warned about — a caution on every page is a caution nobody reads.
	var solo bytes.Buffer
	if err := personPage.Execute(&solo, pageData{Title: "drh", HideH1: true,
		Person: personRow{User: "drh", Events: 9,
			Appearances: []personAppearance{{Repo: "sqlite", Events: 9}}}}); err != nil {
		t.Fatal(err)
	}
	if strings.Contains(solo.String(), "These may be different people") {
		t.Error("a name in one repository is being warned about")
	}
}

// A team is a fact about an ACCOUNT, and this page is about a name on
// artifacts. So the section appears only when an account of that name
// holds a team, and links the team rather than folding it into the
// per-repository rows above.
func TestPersonPageTeamsAreAnAccountFact(t *testing.T) {
	var with bytes.Buffer
	if err := personPage.Execute(&with, pageData{Title: "dmestas", HideH1: true,
		Person: personRow{User: "dmestas",
			Appearances: []personAppearance{{Repo: "quarry", Events: 9}},
			Teams: []teamRow{
				{Name: "firestorm-dataworks", Title: "FireStorm DataWorks", Members: 2, Repos: 1},
			}}}); err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(with.String(), `href="/t/firestorm-dataworks"`) {
		t.Error("a held team is not linked from the person page")
	}

	// No team, no section — and no empty heading either.
	var without bytes.Buffer
	if err := personPage.Execute(&without, pageData{Title: "drh", HideH1: true,
		Person: personRow{User: "drh",
			Appearances: []personAppearance{{Repo: "sqlite", Events: 9}}}}); err != nil {
		t.Fatal(err)
	}
	if strings.Contains(without.String(), `href="/t/`) {
		t.Error("a name holding no team still shows a teams section")
	}
}

// Who may speak is asked apart from who may see: the boxes write the
// participation letters onto the anonymous string, and the preset
// keeps its name whatever is ticked.
func TestParticipationBoxesWriteTheAnonymousString(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a"}}
	srv := peopleServer(t, plane)
	body := asDan(t, srv, "/r/alpha/settings")
	if !strings.Contains(body, `name="part" value="3" checked`) || !strings.Contains(body, `name="part" value="C" checked`) {
		t.Fatalf("a public project does not show forum and chat ticked")
	}
	resp := postAsDan(t, srv, "/r/alpha/settings/participation", url.Values{"part": {"nc"}})
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("status %d", resp.StatusCode)
	}
	if got := plane.cat("alpha", "anonymous"); got != "mnc" {
		t.Fatalf("anonymous after tickets only = %q", got)
	}
	body = asDan(t, srv, "/r/alpha/settings")
	if !strings.Contains(body, `value="Public project" checked`) {
		t.Fatal("clearing a box turned the preset into Custom")
	}
	if strings.Contains(body, `name="part" value="3" checked`) || !strings.Contains(body, `name="part" value="nc" checked`) {
		t.Fatal("the boxes do not read back")
	}
}

// capsRoster adapts the page's caps plane to api.CapsWriter: the STORED
// string, which is what a role change is described by, where the page's
// own CapsOf answers the effective set a gate asks about.
type capsRoster struct{ m *memCaps }

func (c capsRoster) CapsOf(acct, _ string) (string, error) { return c.m.stored[acct], nil }
func (c capsRoster) SetCaps(acct, repo, caps string) error { return c.m.SetCaps(acct, repo, caps) }

// The settings editor writes through account.caps now, not straight to
// the plane — which is the whole point: a role changed here raised
// nothing, so it left no trace in the trail on this very page and
// reached no webhook.
func TestTheEditorRaisesTheAccountEvent(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
	r := ops.NewRegistry()
	ev := &countingEvents{}
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:  api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}}),
		Roster: capsRoster{plane}, Events: ev}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
		WithCaps(plane)}))
	t.Cleanup(srv.Close)

	resp := postAsDan(t, srv, "/r/alpha/people/bob", url.Values{
		"role": {"Maintainer"}, "was-role": {"Developer"},
		"raw": {oltp.Write.Caps()}, "was-raw": {oltp.Write.Caps()},
		"ab": {"u"}, "was-ab": {"u"}})
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("FAIL: status %d", resp.StatusCode)
	}
	if plane.stored["bob"] != oltp.Maintain.Caps() {
		t.Fatalf("FAIL: stored %q — the write did not go through the op", plane.stored["bob"])
	}
	if got := ev.kinds(); len(got) != 1 || got[0] != "account.caps" {
		t.Errorf("FAIL: the editor raised %v, want one account.caps", got)
	}
}

// countingEvents records the kinds an op raised.
type countingEvents struct {
	mu   sync.Mutex
	seen []string
}

func (c *countingEvents) Raise(kind evt.Kind, _ string, _ map[string]any) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.seen = append(c.seen, string(kind))
	return nil
}

func (c *countingEvents) kinds() []string {
	c.mu.Lock()
	defer c.mu.Unlock()
	return append([]string(nil), c.seen...)
}

// SourcesWith for the in-memory plane, in effective()'s order, so the
// editor can name where an inherited ability comes from.
func (m *memCaps) SourcesWith(acct, repo, own string) map[rune]string {
	src := map[rune]string{}
	add := func(letters, from string) {
		for _, r := range letters {
			if _, ok := src[r]; !ok {
				src[r] = from
			}
		}
	}
	add(m.cat(repo, "nobody"), oltp.SourceNobody)
	if acct != "" {
		add(m.cat(repo, "anonymous"), oltp.SourceAnonymous)
		add(own, oltp.SourceOwn)
	}
	if _, ok := src['u']; ok {
		add(m.cat(repo, "reader"), oltp.SourceReader)
	}
	if _, ok := src['v']; ok {
		add(m.cat(repo, "developer"), oltp.SourceDeveloper)
	}
	return src
}

// The editor named ONE source for every inherited ability — "granted to
// everyone signed in" — which on this Developer is wrong for check-in
// (developer category), ticket editing (reader category) and reading the
// code (every visitor). Rendered through the handler: each names its real
// source, inherited boxes are ticked but disabled, and the old phrase is
// gone.
func TestPersonEditorNamesWhereAccessComesFrom(t *testing.T) {
	plane := &memCaps{stored: map[string]string{"dan": "a", "cal": "uv3bBC78"},
		cats: map[string]string{"/reader": "kptw", "/developer": "ei"}}
	// memCaps.EffectiveWith returns the string unexpanded on purpose, and
	// other tests lean on that. This one needs categories expanded, or the
	// editor cannot tell an inherited ability from one not held at all.
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:  api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}}),
		Roster: capsRoster{plane}}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
		WithCaps(expandingCaps{plane})}))
	t.Cleanup(srv.Close)
	page := asDan(t, srv, "/r/alpha/people/cal")
	if strings.Contains(page, "granted to everyone signed in") {
		t.Error("FAIL the fixed phrase is still printed")
	}
	row := func(sentence string) string {
		i := strings.Index(page, "> "+sentence)
		if i < 0 {
			return ""
		}
		start := strings.LastIndex(page[:i], "<label")
		end := strings.Index(page[i:], "</label>")
		return page[start : i+end]
	}
	for sentence, from := range map[string]string{
		"check in and push": "from developer category",
		"edit tickets":      "from reader category",
		"read the code, clone, and download archives": "from every visitor",
		"file tickets": "from anyone signed in",
	} {
		r := row(sentence)
		switch {
		case r == "":
			t.Errorf("FAIL no row for %q", sentence)
		case !strings.Contains(r, from):
			t.Errorf("FAIL %q does not say %q: %s", sentence, from, r)
		case !strings.Contains(r, " checked") || !strings.Contains(r, " disabled"):
			t.Errorf("FAIL %q is not shown held-and-locked: %s", sentence, r)
		default:
			t.Logf("ok %q says %s, ticked and locked", sentence, from)
		}
	}
	// Chat is in the Developer string itself: ticked, editable, no source.
	if r := row("chat"); strings.Contains(r, "disabled") || strings.Contains(r, "ability-from") {
		t.Errorf("FAIL an ability held by the string itself is shown as inherited: %s", r)
	}
}

// expandingCaps is memCaps with categories expanded the way oltp's
// effective() does: visitors, then anonymous and the string, then u and v.
type expandingCaps struct{ *memCaps }

func (e expandingCaps) EffectiveWith(acct, repo, own string) CapSet {
	eff := e.cat(repo, "nobody")
	if acct != "" {
		eff += e.cat(repo, "anonymous") + own
	}
	if strings.ContainsRune(eff, 'u') {
		eff += e.cat(repo, "reader")
	}
	if strings.ContainsRune(eff, 'v') {
		eff += e.cat(repo, "developer")
	}
	return oltp.CapSet(eff)
}