Artifact 409778c5bd1917b8265387dc5f0462d4b2bb0f612b8ae863f7ef036dfef92daf:
- File
internal/harness/caps_test.go
— part of check-in
[86fadebbab]
at
2026-08-23 11:02:22
on branch trunk
— caps: gate private ticket fields on e instead of dropping them
The capability exists now (RFC 0004), so private_contact and contact are withheld by default and rendered to a holder of e. Default deny with no ungated-forge exemption: that rule would publish them. (user: dmestas, size: 8326) [annotate] [blame] [check-ins using]
package harness import ( "encoding/json" "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") } } // Capability e end to end: fossil withholds private_contact from the // public and shows it to holders of e. quarry dropped it outright // because it had no capability to gate on; it has one now, so the // field must reach a holder and nobody else. // // The unit test covers the render rule. This covers the thing that // actually decides it — a real caps plane answering for a real account. func TestPrivateContactReachesOnlyHoldersOfE(t *testing.T) { // The realistic source of this field is an IMPORT: a fossil repo // hosted here brings its own private_contact values with it. quarry // does not author the field, so the test writes it the way a mirror // would — into the store, before the index ever sees it. store := buildStore(t, "eseed") zrun(t, store, "ticket", "new", "--user", "eseed", "title=a bug with a contact", "status=Open", "[email protected]") dataDir := t.TempDir() srv := ingestAndServeAuth(t, map[string]string{"alpha": store}, 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() for _, u := range []string{"plain", "holder"} { if err := odb.AddAccount(u, "pw12345678", false); err != nil { t.Fatal(err) } } // Only one of them may see private contact details. if err := odb.SetCaps("holder", "alpha", "re"); err != nil { t.Fatal(err) } if err := odb.SetCaps("plain", "alpha", "r"); err != nil { t.Fatal(err) } get := func(user, path string) string { req, _ := http.NewRequest("GET", srv.URL+path, nil) req.SetBasicAuth(user, "pw12345678") resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } var list struct { Tickets []struct { ID string `json:"id"` Title string `json:"title"` } `json:"tickets"` } if err := json.Unmarshal([]byte(get("holder", "/api/v1/r/alpha/ticket?state=all&limit=50")), &list); err != nil { t.Fatal(err) } var id string for _, tk := range list.Tickets { if strings.Contains(tk.Title, "a contact") { id = tk.ID } } if id == "" { t.Fatalf("the imported ticket never reached the index: %+v", list.Tickets) } page := "/r/alpha/ticket/" + id if body := get("plain", page); strings.Contains(body, "[email protected]") { t.Error("a reader without e was shown a private contact address") } if body := get("holder", page); !strings.Contains(body, "[email protected]") { t.Error("a holder of e was refused the field the capability names") } }