package harness
import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// capCase is one cell of the capability matrix (docs/rfcs/0004 §4, and
// the two DP-security-matrix diagrams): a surface, the letter that
// opens it, and how to drive it.
type capCase struct {
name string // the surface, as the matrix names it
letter string // the capability that opens it (one letter, per matrix)
method string
path string
body string
}
// The matrix as executable spec. Each row is driven twice: once by an
// account holding ONLY that letter (must pass) and once by an account
// holding every OTHER letter in the table (must fail). The second half
// is what makes this a matrix rather than a smoke test — it proves the
// gate reads its own letter and not merely "some capability".
var capMatrix = []capCase{
{"timeline", "o", "GET", "/api/v1/r/alpha/timeline", ""},
{"tree", "o", "GET", "/api/v1/r/alpha/tree/trunk/", ""},
{"wiki read", "j", "GET", "/api/v1/r/alpha/wiki", ""},
{"ticket read", "r", "GET", "/api/v1/r/alpha/ticket", ""},
{"forum read", "2", "GET", "/api/v1/r/alpha/forum", ""},
{"forum write", "3", "POST", "/api/v1/r/alpha/forum", "title=t&body=b"},
// Back to `n`: ticket.new follows the REPOSITORY's tier now, so
// filing is an ordinary act again and permanence is a repository
// policy (internal/api's TestANewTicketFollowsTheRepositorysTier).
// The harness wires no local tier, so this route still writes the
// artifact log here — which is what a fossil-tier repository does.
{"ticket file", "n", "POST", "/api/v1/r/alpha/ticket", "title=t&status=Open"},
{"wiki write", "k", "POST", "/api/v1/r/alpha/wiki/Guide", "body=hello"},
{"moderation queue", "5", "GET", "/api/v1/r/alpha/forum-queue", ""},
{"repo settings", "a", "POST", "/api/v1/repos/update", "repo=alpha&description=d"},
}
// everyOtherLetter is the union of the matrix's letters minus one —
// the "holds everything except the key" account. s and a are excluded
// from the union because they are umbrella caps that legitimately open
// everything (RFC 0004 §3).
// everyOtherLetter is every matrix letter except the one asked about
// and every letter that IMPLIES it.
//
// The implicants matter, and leaving them in made this half of the
// test assert the wrong thing. fossil's implies table — which quarry
// follows — says k implies jm, 3 implies 2, and each forum level
// implies the one below, so an account given "everything but j" still
// holds k and still reads the wiki. That is correct behaviour, and the
// negative case was only ever a real refusal test for letters nothing
// else confers.
//
// Dropping the implicants makes the assertion the one worth making:
// no letter that does NOT imply this one opens this surface.
func everyOtherLetter(except string) string {
var b strings.Builder
for _, c := range capMatrix {
if c.letter == except || c.letter == "a" {
continue
}
// Does this letter confer the one we are withholding?
if except != "" && strings.Contains(oltp.Closure(c.letter), except) {
continue
}
if !strings.Contains(b.String(), c.letter) {
b.WriteString(c.letter)
}
}
return b.String()
}
func TestCapabilityMatrix(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "matrixseed")}
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 // capabilities decide from here
})
odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
defer odb.Close()
// Categories blanked: this test is about EXPLICIT caps, and the
// stock categories would hand every account half the matrix.
for _, cat := range []string{"nobody", "anonymous", "reader", "developer"} {
if err := odb.SetCategoryCaps("alpha", cat, ""); err != nil {
t.Fatal(err)
}
}
account := func(name, caps string) {
t.Helper()
if err := odb.AddAccount(name, "pw12345678", false); err != nil {
t.Fatal(err)
}
if err := odb.SetCaps(name, "alpha", caps); err != nil {
t.Fatal(err)
}
}
call := func(user string, c capCase) int {
var r io.Reader
if c.body != "" {
r = strings.NewReader(c.body)
}
req, _ := http.NewRequest(c.method, srv.URL+c.path, r)
req.SetBasicAuth(user, "pw12345678")
if c.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
}
for i, c := range capMatrix {
// One pair of accounts per ROW, not per letter: a letter can
// open more than one surface.
holder := fmt.Sprintf("holds%d", i)
account(holder, c.letter)
if got := call(holder, c); got == http.StatusForbidden {
t.Errorf("%s: cap %q did not open it (403)", c.name, c.letter)
}
// The negative half: everything except this letter.
other := fmt.Sprintf("lacks%d", i)
account(other, everyOtherLetter(c.letter))
if got := call(other, c); got != http.StatusForbidden {
t.Errorf("%s: opened without %q (status %d, caps %q)",
c.name, c.letter, got, everyOtherLetter(c.letter))
}
}
}