package web
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// editRecorder is a repository registry that remembers every settings
// patch it was asked to apply, which is the only way to tell a write
// that changed nothing from one that changed a field to its zero value.
type editRecorder struct {
mu sync.Mutex
edits []api.RepoEdit
}
func (e *editRecorder) Add(api.RepoSpec) (string, string, error) { return "", "", nil }
func (e *editRecorder) Owner(a string) (string, error) { return a, nil }
func (e *editRecorder) NameFree(string, string, string) error { return nil }
func (e *editRecorder) Remove(string) error { return nil }
func (e *editRecorder) Delete(string, string) error { return nil }
func (e *editRecorder) Update(_ string, edit api.RepoEdit, _, _ string) error {
e.mu.Lock()
defer e.mu.Unlock()
e.edits = append(e.edits, edit)
return nil
}
func (e *editRecorder) Imports() ([]api.RepoImport, error) { return nil, nil }
func (e *editRecorder) ConfirmImport(string, string) error { return nil }
func (e *editRecorder) count() int {
e.mu.Lock()
defer e.mu.Unlock()
return len(e.edits)
}
func (e *editRecorder) last() api.RepoEdit {
e.mu.Lock()
defer e.mu.Unlock()
return e.edits[len(e.edits)-1]
}
func aboutServer(t *testing.T, deps func(*api.Deps)) (*httptest.Server, *editRecorder) {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
rec := &editRecorder{}
d := api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}), Registry: rec}
if deps != nil {
deps(&d)
}
r := ops.NewRegistry()
if err := api.RegisterAll(r, d); err != nil {
t.Fatal(err)
}
verify := Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
WithCaps(stubCaps{letters: "a"}),
}, verify))
t.Cleanup(srv.Close)
return srv, rec
}
// A body that names none of the About form's fields changes nothing.
// What happened on 2026-11-12: a worker-token mint's `label=` body was
// re-POSTed to the About form fifty times, and each one read the absent
// checkboxes as "off" (quarry#424).
func TestAboutFormRefusesABodyThatDoesNotNameIt(t *testing.T) {
srv, rec := aboutServer(t, nil)
resp := postAsDan(t, srv, "/r/alpha/settings", url.Values{"label": {"x"}})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("FAIL: a body naming no form answered %d, want 400", resp.StatusCode)
}
if rec.count() != 0 {
t.Errorf("FAIL: a refused post still wrote %d settings patch(es)", rec.count())
}
t.Log("ok")
}
// Named, but with no checkbox drawn: nothing boolean may change, and
// neither may a text field the body did not carry.
func TestAboutFormMarkerAloneChangesNothingBoolean(t *testing.T) {
srv, rec := aboutServer(t, nil)
resp := postAsDan(t, srv, "/r/alpha/settings", url.Values{"form": {"about"}})
if resp.StatusCode >= 400 {
t.Fatalf("FAIL: the marker alone answered %d", resp.StatusCode)
}
if rec.count() > 0 {
e := rec.last()
if e.Chat != nil || e.UV != nil || e.Mirrorable != nil {
t.Errorf("FAIL: a boolean was written with no checkbox in the body: %+v", e)
}
if e.Description != nil || e.Homepage != nil || e.Topics != nil {
t.Errorf("FAIL: a text field was written with none in the body: %+v", e)
}
}
t.Log("ok")
}
// The real form round-trips: a box that was drawn and left unticked is
// off, one that was ticked is on, and a disabled box (Mirrorable on a
// private repository draws none) is left alone.
func TestAboutFormRoundTripsDrawnCheckboxes(t *testing.T) {
srv, rec := aboutServer(t, nil)
resp := postAsDan(t, srv, "/r/alpha/settings", url.Values{
"form": {"about"}, "description": {"d"}, "homepage": {""}, "topics": {""},
"has": {"chat", "uv", "mirrorable"}, "uv": {"on"},
})
if resp.StatusCode >= 400 {
t.Fatalf("FAIL: the real form answered %d", resp.StatusCode)
}
if rec.count() != 1 {
t.Fatalf("FAIL: want one patch, got %d", rec.count())
}
e := rec.last()
if e.Chat == nil || *e.Chat {
t.Errorf("FAIL: an unticked drawn Chat box was not read as off: %+v", e.Chat)
}
if e.UV == nil || !*e.UV {
t.Errorf("FAIL: a ticked UV box was not read as on: %+v", e.UV)
}
if e.Mirrorable == nil || *e.Mirrorable {
t.Errorf("FAIL: an unticked drawn Mirrorable box was not read as off: %+v", e.Mirrorable)
}
if e.Description == nil || *e.Description != "d" {
t.Errorf("FAIL: the description did not arrive: %+v", e.Description)
}
// A disabled box posts nothing and its `has` is not drawn.
resp = postAsDan(t, srv, "/r/alpha/settings", url.Values{
"form": {"about"}, "description": {"d2"}, "has": {"chat", "uv"}, "chat": {"on"}, "uv": {"on"},
})
if resp.StatusCode >= 400 {
t.Fatalf("FAIL: the form without Mirrorable answered %d", resp.StatusCode)
}
if e = rec.last(); e.Mirrorable != nil {
t.Errorf("FAIL: an undrawn (disabled) Mirrorable was written: %v", *e.Mirrorable)
}
t.Log("ok")
}
// The page itself draws the marker and one `has` per drawn checkbox,
// and none for a disabled Mirrorable.
func TestAboutFormDrawsMarkerAndPresenceFields(t *testing.T) {
has := func(private bool) []string {
body := renderSettings(t, pageData{Repo: "alpha", RepoPrivate: private, Caps: &capsPanel{}})
if !strings.Contains(body, `name="form" value="about"`) {
t.Errorf("FAIL: the About form does not carry its marker (private=%v)", private)
}
var got []string
rest := body
for {
i := strings.Index(rest, `name="has" value="`)
if i < 0 {
break
}
rest = rest[i+len(`name="has" value="`):]
got = append(got, rest[:strings.Index(rest, `"`)])
}
return got
}
if got := strings.Join(has(false), " "); !strings.Contains(got, "chat") || !strings.Contains(got, "uv") || !strings.Contains(got, "mirrorable") {
t.Errorf("FAIL: a public repository draws presence fields %q", got)
}
if got := strings.Join(has(true), " "); strings.Contains(got, "mirrorable") {
t.Errorf("FAIL: a disabled Mirrorable still draws its presence field: %q", got)
}
t.Log("ok")
}
// stubIn takes every field any guarded route sends, as a string, so one
// recording op can stand behind each of them.
type stubIn struct {
Repo string `json:"repo"`
Name string `json:"name"`
ID string `json:"id"`
Agent string `json:"agent"`
On string `json:"on"`
Listed string `json:"listed"`
Private string `json:"private"`
Title string `json:"title"`
Descr string `json:"descr"`
Mode string `json:"mode"`
State string `json:"state"`
Accept string `json:"accept"`
Answerable string `json:"answerable"`
Email string `json:"email"`
Body string `json:"body"`
Account string `json:"account"`
Role string `json:"role"`
Post string `json:"post"`
Root string `json:"root"`
Event string `json:"event"`
Comment string `json:"comment"`
Remove string `json:"remove"`
Description string `json:"description"`
Events string `json:"events"`
Archives string `json:"archives"`
DeleteObjects string `json:"deleteObjects"`
}
type stubOut struct {
OK bool `json:"ok"`
}
// guardPlane is a capability plane that also records the two settings
// the repository page writes through the plane itself.
type guardPlane struct {
stubCaps
mu sync.Mutex
tiers, modes []string
}
func (g *guardPlane) TicketTier(string) string { return "" }
func (g *guardPlane) SetTicketTier(_, tier string) error {
g.mu.Lock()
defer g.mu.Unlock()
g.tiers = append(g.tiers, tier)
return nil
}
func (g *guardPlane) CITagsMode(string) string { return "" }
func (g *guardPlane) SetCITagsMode(_, mode string) error {
g.mu.Lock()
defer g.mu.Unlock()
g.modes = append(g.modes, mode)
return nil
}
// guardServer stands a recording op behind every op a guarded route
// calls: a request that gets past its guard REACHES the stub, so a
// route whose guard is removed is seen to change something.
func guardServer(t *testing.T, opNames []string) (*httptest.Server, *guardPlane, func(string) int) {
srv, plane, called, _ := guardServerIn(t, opNames)
return srv, plane, called
}
// guardServerIn is guardServer that also reports the last input each
// stub op received.
func guardServerIn(t *testing.T, opNames []string) (*httptest.Server, *guardPlane, func(string) int, func(string) stubIn) {
t.Helper()
var mu sync.Mutex
calls := map[string]int{}
last := map[string]stubIn{}
r := ops.NewRegistry()
for _, name := range opNames {
name := name
if err := ops.Register(r, ops.Op[stubIn, stubOut]{
Name: name, Doc: "records that it was called", Kind: ops.Command, Auth: ops.Anonymous,
Route: "POST /api/v1/stub/" + name,
Run: func(_ context.Context, in stubIn) (stubOut, error) {
mu.Lock()
defer mu.Unlock()
calls[name]++
last[name] = in
return stubOut{OK: true}, nil
},
}); err != nil {
t.Fatal(err)
}
}
// The refusal page reads the version for its chrome.
if err := ops.Register(r, ops.Op[stubIn, struct {
Version string `json:"version"`
}]{
Name: "meta.version", Doc: "the version", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/stub/version",
Run: func(context.Context, stubIn) (struct {
Version string `json:"version"`
}, error) {
return struct {
Version string `json:"version"`
}{"v"}, nil
},
}); err != nil {
t.Fatal(err)
}
plane := &guardPlane{stubCaps: stubCaps{letters: "aC3kfeimnopqrtvwxyz8LS"}}
verify := Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin, "": ops.Admin}}),
WithCaps(plane),
}, verify))
t.Cleanup(srv.Close)
return srv, plane, func(name string) int {
mu.Lock()
defer mu.Unlock()
return calls[name]
}, func(name string) stubIn {
mu.Lock()
defer mu.Unlock()
return last[name]
}
}
// Every route below changes a stored value from a field of its own form
// or toggle. For each: a body carrying none of that form's fields is
// refused with 400 and the op behind it is never reached; the same route
// with its field does reach it. The second half is what makes the first
// mean something: with the guard removed, the stray body reaches the op
// too, and the test fails.
func TestGuardedRoutesRefuseAStrayBodyAndAcceptTheirOwn(t *testing.T) {
cases := []struct {
path, op string
valid url.Values
}{
{"/t/ops/listed", "team.listed", url.Values{"listed": {"true"}}},
{"/t/ops/private", "team.private", url.Values{"private": {"true"}}},
{"/t/ops/text", "team.text", url.Values{"form": {"team-text"}, "title": {"x"}}},
{"/t/ops/member", "team.member", url.Values{"account": {"bob"}, "role": {""}}},
{"/me/agents/bot/ask", "agent.ask", url.Values{"on": {"true"}}},
{"/me/agents/bot/inherit", "agent.inherit", url.Values{"on": {"true"}}},
{"/r/alpha/stamp", "stamp.set", url.Values{"on": {"1"}}},
{"/r/alpha/landing/abc123/auto", "landing.auto", url.Values{"on": {"true"}}},
{"/fleet/people/disable", "account.disable", url.Values{"name": {"bob"}, "on": {"true"}}},
{"/fleet/people/answerable", "account.answerable", url.Values{"name": {"bob"}, "answerable": {"dan"}}},
{"/fleet/people/email", "account.email.set", url.Values{"name": {"bob"}, "email": {"[email protected]"}}},
{"/fleet/mirror/writes", "mirror.writes", url.Values{"repo": {"alpha"}, "accept": {"true"}}},
{"/r/alpha/settings/mirror/writes", "mirror.writes", url.Values{"form": {"mirror-writes"}, "accept": {"1"}}},
{"/r/alpha/settings/robot", "chat.robot", url.Values{"form": {"robot"}, "events": {"checkin"}}},
{"/r/alpha/settings/bucket/policy", "bucket.policy", url.Values{"form": {"bucket-policy"}, "has": {"archives"}, "archives": {"1"}}},
{"/r/alpha/settings", "repo.update", url.Values{"form": {"about"}, "description": {"d"}}},
{"/r/alpha/chat/notify", "chat.notify.set", url.Values{"mode": {"all"}}},
{"/r/alpha/forum/abc123/subscribe", "forum.subscribe", url.Values{"state": {"on"}}},
{"/r/alpha/wiki/Home/edit", "wiki.save", url.Values{"body": {"text"}}},
{"/r/alpha/forum/abc123/edit/def456", "forum.edit", url.Values{"body": {"text"}}},
{"/r/alpha/technote/abc123/edit", "technote.add", url.Values{"body": {"text"}}},
}
var names []string
for _, c := range cases {
names = append(names, c.op)
}
sort.Strings(names)
names = slices.Compact(names)
srv, _, called, _ := guardServerIn(t, names)
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
before := called(c.op)
if resp := postAsDan(t, srv, c.path, url.Values{"label": {"x"}}); resp.StatusCode != http.StatusBadRequest {
b, _ := io.ReadAll(resp.Body)
t.Errorf("FAIL: a stray body answered %d, want 400: %.200s", resp.StatusCode, b)
}
if got := called(c.op); got != before {
t.Errorf("FAIL: a stray body reached %s", c.op)
}
// The fields in the URL are not the form: a stray body whose
// query string carries the route's own fields is still stray.
if resp := postAsDan(t, srv, c.path+"?"+c.valid.Encode(), url.Values{"label": {"x"}}); resp.StatusCode != http.StatusBadRequest {
t.Errorf("FAIL: the route's fields in the query string answered %d, want 400", resp.StatusCode)
}
if got := called(c.op); got != before {
t.Errorf("FAIL: the route's fields in the query string reached %s", c.op)
}
if resp := postAsDan(t, srv, c.path, c.valid); resp.StatusCode >= 400 {
t.Errorf("FAIL: the route's own body answered %d", resp.StatusCode)
}
if called(c.op) != before+1 {
t.Errorf("FAIL: the route's own body did not reach %s", c.op)
}
})
}
t.Log("ok")
}
// team.text reads the body only: a named form with no title in it, posted
// to a URL that carries one, sets no title.
func TestTeamTextIgnoresTheQueryString(t *testing.T) {
srv, _, called, lastIn := guardServerIn(t, []string{"team.text"})
if resp := postAsDan(t, srv, "/t/ops/text?title=zzz&descr=yyy", url.Values{"form": {"team-text"}, "descr": {"kept"}}); resp.StatusCode >= 400 {
t.Fatalf("FAIL: answered %d", resp.StatusCode)
}
if called("team.text") != 1 {
t.Fatal("FAIL: the op was not reached")
}
if in := lastIn("team.text"); in.Title != "" || in.Descr != "kept" {
t.Errorf("FAIL: the op was given %+v; the query string leaked in", in)
}
t.Log("ok")
}
// The two settings the repository page writes through the plane, and
// the appearance cookies, with the same proof: the route's own body
// writes, a stray one writes nothing.
func TestGuardedPlaneRoutesAndCookies(t *testing.T) {
srv, plane, _ := guardServer(t, nil)
for _, c := range []struct {
path string
valid url.Values
wrote func() int
}{
{"/r/alpha/settings/ticket-tier", url.Values{"tier": {"fossil"}}, func() int { return len(plane.tiers) }},
{"/r/alpha/settings/ci-tags", url.Values{"mode": {"on"}}, func() int { return len(plane.modes) }},
} {
t.Run(c.path, func(t *testing.T) {
if resp := postAsDan(t, srv, c.path, url.Values{"label": {"x"}}); resp.StatusCode != http.StatusBadRequest {
b, _ := io.ReadAll(resp.Body)
t.Errorf("FAIL: a stray body answered %d, want 400: %.300s", resp.StatusCode, b)
}
if c.wrote() != 0 {
t.Errorf("FAIL: a stray body wrote the setting")
}
if resp := postAsDan(t, srv, c.path, c.valid); resp.StatusCode >= 400 {
t.Errorf("FAIL: its own body answered %d", resp.StatusCode)
}
if c.wrote() != 1 {
t.Errorf("FAIL: its own body did not write the setting")
}
})
}
t.Run("/me/appearance", func(t *testing.T) {
resp := postAsDan(t, srv, "/me/appearance", url.Values{"label": {"x"}})
if resp.StatusCode != http.StatusBadRequest || len(resp.Cookies()) != 0 {
t.Errorf("FAIL: a stray body answered %d and set %d cookies", resp.StatusCode, len(resp.Cookies()))
}
resp = postAsDan(t, srv, "/me/appearance", url.Values{"site": {""}, "code": {""}})
if resp.StatusCode >= 400 || len(resp.Cookies()) != 2 {
t.Errorf("FAIL: its own body answered %d and set %d cookies, want 2", resp.StatusCode, len(resp.Cookies()))
}
})
t.Log("ok")
}
// Participation and protection are written through planes that keep
// state, so they are proved by reading it back: a stray body leaves the
// anonymous string and the rule set as they were.
func TestParticipationAndProtectionKeepTheirStateOnAStrayBody(t *testing.T) {
caps := &memCaps{stored: map[string]string{"dan": "a"}}
prot := &recProtect{set: map[string][]string{}, rules: map[string][]oltp.Protection{
"alpha": {{Branch: "trunk", Owners: []string{"alice"}}}}}
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v", Protect: prot,
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}})}); 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(caps), WithProtect(prot)}))
t.Cleanup(srv.Close)
before := caps.cat("alpha", "anonymous")
if resp := postAsDan(t, srv, "/r/alpha/settings/participation", url.Values{"label": {"x"}}); resp.StatusCode != http.StatusBadRequest {
t.Errorf("FAIL: participation answered %d to a stray body", resp.StatusCode)
}
if got := caps.cat("alpha", "anonymous"); got != before {
t.Errorf("FAIL: a stray body changed the anonymous string %q -> %q", before, got)
}
postAsDan(t, srv, "/r/alpha/settings/participation", url.Values{"form": {"participation"}, "part": {"nc"}})
if got := caps.cat("alpha", "anonymous"); got == before {
t.Errorf("FAIL: the form's own body changed nothing, so the test above proves nothing")
}
if resp := postAsDan(t, srv, "/r/alpha/settings/protect", url.Values{"label": {"x"}}); resp.StatusCode != http.StatusBadRequest {
t.Errorf("FAIL: protect answered %d to a stray body", resp.StatusCode)
}
if len(prot.set) != 0 {
t.Errorf("FAIL: a stray body rewrote the protection rules: %v", prot.set)
}
postAsDan(t, srv, "/r/alpha/settings/protect", url.Values{"form": {"protect"}})
if _, cleared := prot.set["trunk"]; !cleared {
t.Errorf("FAIL: the form's own (empty) body did not clear the unnamed rule, so the test above proves nothing")
}
t.Log("ok")
}
// ---- worker tokens through the ops ----
func tokenServer(t *testing.T, eng *mintRecorder, letters string) *httptest.Server {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v", WorkerTokens: eng,
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
t.Fatal(err)
}
verify := Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
WithCaps(stubCaps{letters: letters}),
WithEngine(eng),
}, verify))
t.Cleanup(srv.Close)
return srv
}
// The web form mints and revokes through the ops: the group scope the op
// sets is what reaches the engine, and revoke of another repository's
// token is not found.
func TestWorkerTokenFormGoesThroughTheOps(t *testing.T) {
eng := &mintRecorder{tokens: tokensFixture()}
srv := tokenServer(t, eng, "a")
resp := postAsDan(t, srv, "/r/alpha/settings/worker-tokens", url.Values{"label": {"runner"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("FAIL: the mint answered %d", resp.StatusCode)
}
eng.mu.Lock()
groups, prefixes := eng.groups, eng.prefixes
eng.mu.Unlock()
want := ci.GroupFor("alpha")
if len(groups) != 1 || groups[0] != want || len(prefixes) != 1 || prefixes[0] != want {
t.Errorf("FAIL: minted with groups %v prefixes %v, want only %q", groups, prefixes, want)
}
if loc := resp.Header.Get("Location"); strings.Contains(loc, "tok1") || strings.Contains(loc, "s&") {
t.Errorf("FAIL: the redirect carries the minted value")
}
if resp := postAsDan(t, srv, "/r/alpha/settings/worker-tokens", url.Values{"revoke": {"beta-token-1"}}); resp.StatusCode != http.StatusNotFound {
t.Errorf("FAIL: revoking another repository's token answered %d, want 404", resp.StatusCode)
}
if resp := postAsDan(t, srv, "/r/alpha/settings/worker-tokens", url.Values{"revoke": {"fleet-token-1"}}); resp.StatusCode != http.StatusNotFound {
t.Errorf("FAIL: revoking a fleet-wide token answered %d, want 404", resp.StatusCode)
}
if resp := postAsDan(t, srv, "/r/alpha/settings/worker-tokens", url.Values{"revoke": {"own-token-1"}}); resp.StatusCode != http.StatusSeeOther {
t.Errorf("FAIL: revoking its own token answered %d", resp.StatusCode)
}
eng.mu.Lock()
revoked := append([]string(nil), eng.revoked...)
eng.mu.Unlock()
if len(revoked) != 1 || revoked[0] != "own-token-1" {
t.Errorf("FAIL: the engine revoked %v, want only own-token-1", revoked)
}
t.Log("ok")
}