Artifact af53d09b860790fde85ef7e925c910884a1b3e9691e9afa7259032541599e545:
- File
internal/harness/caps_test.go
— part of check-in
[54cc5168ca]
at
2026-08-22 16:23:35
on branch fossil-caps
— caps phase 5a: web write paths gate on capabilities
gateCap on every forum/wiki/ticket/technote/chat POST; caps plane separated from sessions (WithCaps) so enforcement doesn't imply a login page; an ungated forge stays ungated. Harness proves category caps reach surfaces and that removing a letter closes the same request. (user: dmestas, size: 5615) [annotate] [blame] [check-ins using] [more...]
package harness import ( "io" "net/http" "path/filepath" "strings" "testing" "craftdesign.group/quarry/internal/api" "craftdesign.group/quarry/internal/oltp" "craftdesign.group/quarry/internal/ops" ) // TestCapsGateTheAPI is RFC 0004 §4 enforced end to end: each account // holds exactly one fossil capability letter and may reach exactly the // surfaces that letter opens. The Auth floor lets every authenticated // caller through; the capability check is what decides. func TestCapsGateTheAPI(t *testing.T) { stores := map[string]string{"alpha": buildStore(t, "capseed")} dataDir := t.TempDir() srv := ingestAndServeAuth(t, stores, dataDir, func(repo, user, pass string, need ops.Auth) api.Decision { if user == "" { return api.Denied } return api.Allowed }) // The harness opened this same file; a second handle sees its rows. odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db")) if err != nil { t.Fatal(err) } defer odb.Close() must := func(err error) { t.Helper() if err != nil { t.Fatal(err) } } must(odb.AddAccount("reader", "pw12345678", false)) must(odb.SetCaps("reader", "alpha", "o")) // Read must(odb.AddAccount("forumer", "pw12345678", false)) must(odb.SetCaps("forumer", "alpha", "3")) // WrForum call := func(user, method, path, body string) int { var r io.Reader if body != "" { r = strings.NewReader(body) } req, _ := http.NewRequest(method, srv.URL+path, r) req.SetBasicAuth(user, "pw12345678") if body != "" { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } // o reads. if got := call("reader", "GET", "/api/v1/r/alpha/timeline", ""); got != http.StatusOK { t.Fatalf("o cannot read the timeline: %d", got) } // o is refused the forum write surface; 3 is not. if got := call("reader", "POST", "/api/v1/r/alpha/forum", "title=x&body=y"); got != http.StatusForbidden { t.Fatalf("o opened the forum write surface: %d", got) } if got := call("forumer", "POST", "/api/v1/r/alpha/forum", "title=from+the+forum+cap&body=hello"); got != http.StatusOK { t.Fatalf("3 cannot write the forum: %d", got) } // 3 does not carry repository administration (a). if got := call("forumer", "POST", "/api/v1/repos", "name=nope&create=1"); got == http.StatusOK { t.Fatal("3 reached repository creation") } // The WEB forms answer to the same letters as the API — a page that // gates more loosely than its op is a hole with a nice layout. if got := call("reader", "POST", "/r/alpha/forum-new", "title=x&body=y"); got != http.StatusForbidden { t.Fatalf("o posted a thread through the web form: %d", got) } // 3 posts successfully: a redirect to the new thread, or the page // itself — either way, NOT a refusal. if got := call("forumer", "POST", "/r/alpha/forum-new", "title=web+thread&body=hello"); got == http.StatusForbidden || got == http.StatusUnauthorized { t.Fatalf("3 cannot post through the web form: %d", got) } // Ticket filing needs n — which the ANONYMOUS category grants to // every signed-in account (fossil's hmnc default). So this passes, // and that is the rule, not a leak: a signed-in human may file a // ticket unless the repo says otherwise. if got := call("forumer", "POST", "/r/alpha/ticket/new", "title=t&comment=c"); got == http.StatusForbidden { t.Fatalf("the anonymous category did not grant n: %d", got) } // Take n away from the category and the same request is refused — // which is what proves the gate reads caps rather than waving // signed-in callers through. if err := odb.SetCategoryCaps("alpha", "anonymous", "h"); err != nil { t.Fatal(err) } if got := call("forumer", "POST", "/r/alpha/ticket/new", "title=t2&comment=c"); got != http.StatusForbidden { t.Fatalf("ticket filing survived the loss of n: %d", got) } } // TestCategoryCapsReachTheSurfaces: an account with NO explicit caps // still reaches what the anonymous category grants, and a repo taken // private (blank nobody+anonymous) closes even the read surfaces — // fossil's Security-Audit behavior, which is quarry's invisibility. func TestCategoryCapsReachTheSurfaces(t *testing.T) { stores := map[string]string{"alpha": buildStore(t, "catseed")} dataDir := t.TempDir() srv := ingestAndServeAuth(t, stores, dataDir, func(repo, user, pass string, need ops.Auth) api.Decision { if user == "" { return api.Denied } return api.Allowed }) odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db")) if err != nil { t.Fatal(err) } defer odb.Close() if err := odb.AddAccount("plain", "pw12345678", false); err != nil { t.Fatal(err) } get := func(user, path string) int { req, _ := http.NewRequest("GET", srv.URL+path, nil) if user != "" { req.SetBasicAuth(user, "pw12345678") } resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } // Stock nobody carries o: the timeline reads for everyone. if got := get("plain", "/api/v1/r/alpha/timeline"); got != http.StatusOK { t.Fatalf("category caps do not reach the timeline: %d", got) } // Take it private: blank the two visitor categories. if err := odb.SetCategoryCaps("alpha", "nobody", ""); err != nil { t.Fatal(err) } if err := odb.SetCategoryCaps("alpha", "anonymous", ""); err != nil { t.Fatal(err) } if got := get("plain", "/api/v1/r/alpha/timeline"); got == http.StatusOK { t.Fatal("a private repo still served its timeline to a capless account") } }