Skip to content
quarry Artifact [f9f6386781]
Login

Artifact [f9f6386781]

Artifact f9f638678170ad72b35c52852f0a7c3afd6e0365a56a20bcfcc8c884ce5ac9e0:


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).
func everyOtherLetter(except string) string {
	var b strings.Builder
	for _, c := range capMatrix {
		if c.letter == except || c.letter == "a" {
			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))
		}
	}
}