Skip to content
quarry Artifact [3c4fedb5fe]
Login

Artifact [3c4fedb5fe]

Artifact 3c4fedb5fe2a4d027d16c479c286e60308b8c698a0aa2fa15b2128b6441ae4f9:


// Package harness runs the disposability loop (0001 §11): ingest real
// stores, snapshot every rendered surface, rm -rf the derived data
// directory, re-ingest from the stores, snapshot again, and semantically
// diff. Any diff is hostage state and fails the build. The fleet version
// runs two stores through one process — shared NATS, per-repo indexes —
// and asserts they cannot bleed into each other.
package harness

import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/events"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ingest"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/semdiff"
	"craftdesign.group/quarry/internal/web"
	"craftdesign.group/quarry/internal/zitcli"
)

type surface struct {
	path string
	kind semdiff.Kind
}

// surfaces enumerates what gets snapshotted for one repo, plus the
// fleet-level pages.
func surfaces(repos []string) []surface {
	out := []surface{
		{"/", semdiff.HTML},
		{"/api/v1/repos", semdiff.JSON},
		{"/api/v1/version", semdiff.JSON},
		{"/api/v1/openapi.json", semdiff.JSON},
	}
	for _, r := range repos {
		p := "/r/" + r
		a := "/api/v1/r/" + r
		out = append(out,
			surface{p + "/timeline", semdiff.HTML},
			surface{p + "/wiki", semdiff.HTML},
			surface{p + "/wiki/Guide", semdiff.HTML},
			surface{p + "/wiki/Guide/history", semdiff.HTML},
			surface{a + "/wiki/Guide", semdiff.JSON},
			surface{a + "/wiki/Guide/history", semdiff.JSON},
			surface{p + "/ticket", semdiff.HTML},
			surface{p + "/search?q=README", semdiff.HTML},
			surface{a + "/timeline", semdiff.JSON},
			surface{a + "/wiki", semdiff.JSON},
			surface{a + "/ticket", semdiff.JSON},
			surface{a + "/search?q=README", semdiff.JSON},
		)
	}
	return out
}

func zrun(t *testing.T, dir string, args ...string) {
	t.Helper()
	cmd := exec.Command("zit", args...)
	cmd.Dir = dir
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("zit %v: %v\n%s", args, err, out)
	}
}

// buildStore creates one store under test: a zit repository with a few
// commits carrying a distinguishing seed word.
func buildStore(t *testing.T, seed string) string {
	t.Helper()
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	repo := t.TempDir()
	zrun(t, repo, "init")
	for _, f := range []struct{ name, body, msg string }{
		{"README.md", "# store " + seed + "\n", "add README " + seed},
		{"src/main.go", "package " + seed + "\n", "add main"},
		{"README.md", "# store " + seed + "\nmore\n", "extend README"},
	} {
		if err := os.MkdirAll(filepath.Dir(filepath.Join(repo, f.name)), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(filepath.Join(repo, f.name), []byte(f.body), 0o644); err != nil {
			t.Fatal(err)
		}
		zrun(t, repo, "add", f.name)
		zrun(t, repo, "commit", "-m", f.msg)
	}
	// Non-checkin artifacts too (zit#71 verbs): the disposability rule
	// covers every rendered surface, wiki and tickets included.
	wiki := exec.Command("zit", "wiki", "commit", "Guide", "--user", seed, "--mimetype", "text/x-markdown")
	wiki.Dir = repo
	wiki.Stdin = strings.NewReader("# Guide\n\n" + seed + " wiki body\n")
	if out, err := wiki.CombinedOutput(); err != nil {
		t.Fatalf("zit wiki commit: %v\n%s", err, out)
	}
	zrun(t, repo, "ticket", "new", "--user", seed, "title="+seed+" ticket", "status=Open")
	return repo
}

// ingestAndServe is the from-nothing build: one embedded NATS and one
// index per repo, all under dataDir (so the wipe destroys stream,
// cursors, and every table), each store flowing watcher → events → fold
// under its own project code, both registry consumers on top.
func ingestAndServe(t *testing.T, repos map[string]string, dataDir string) *httptest.Server {
	t.Helper()
	return ingestAndServeAuth(t, repos, dataDir, nil)
}

func ingestAndServeAuth(t *testing.T, repos map[string]string, dataDir string, verify api.Verify) *httptest.Server {
	t.Helper()
	return ingestAndServeAuthMod(t, repos, dataDir, verify, nil, nil, nil)
}

// ingestAndServeAuthMod additionally wires the moderation trust seam
// and the AI hook, both nil-safe.
func ingestAndServeAuthMod(t *testing.T, repos map[string]string, dataDir string, verify api.Verify,
	trusted func(account, repo string) bool,
	hook func(context.Context, oltp.Staged) (api.HookVerdict, error),
	notify api.NotifyPlane) *httptest.Server {
	t.Helper()
	ev, err := events.Start(filepath.Join(dataDir, "nats"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(ev.Close)
	// The vote plane is wired exactly as the server wires it (one OLTP
	// under dataDir), so vote state dies with the wipe like all forge
	// state — and its absence would silently unregister forum.vote.
	odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { odb.Close() })
	// The chat plane is wired exactly as the server wires it, so a
	// reference typed into chat reaches the artifact page the same way
	// in this harness as in production (RFC 0011 §14).
	room, err := chat.New(ev.Conn(), ev.JS())
	if err != nil {
		t.Fatal(err)
	}
	deps := api.Deps{Version: "0.0.0-harness", Repos: api.NewRepos(nil), Votes: odb, Forum: odb,
		Chat: room, ChatRefs: odb,
		TrustedAuthor: trusted, ForumHook: hook, Notify: notify}
	// RepoMeta mirrors main's wiring so topic search behaves as deployed.
	deps.RepoMeta = func() map[string]api.RepoMeta {
		rows, err := odb.Repos()
		if err != nil {
			return nil
		}
		m := make(map[string]api.RepoMeta, len(rows))
		for _, row := range rows {
			m[row.Name] = api.RepoMeta{Homepage: row.Homepage, Topics: strings.Fields(row.Topics)}
		}
		return m
	}
	for name, path := range repos {
		ix, err := index.Open(filepath.Join(dataDir, name+".db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		cli := zitcli.New(path)
		pipe := ingest.NewPipeline(cli, ev, ix, name, "harness")
		// Leaves must be wired here exactly as the server wires it, or
		// the harness proves things about a quarry that does not exist:
		// leaf status comes from zit, and an unwired provider silently
		// marks nothing.
		zc := cli
		// Code is the chat channel key (0001 §5); production sets it
		// from the store's project code, and without it chat.post
		// publishes to a subject no stream holds.
		deps.Repos.Put(name, &api.Repo{Index: ix, Zit: cli, Sync: pipe.Sync, Code: name,
			Leaves: func() (map[string]bool, error) { return zc.Leaves(context.Background()) }})
		if _, err := pipe.Sync(); err != nil {
			t.Fatal(err)
		}
	}
	// The ops need the capability resolver too, not just the router:
	// a per-token test (RFC 0011 §14) is a no-op without it, and
	// production wires both from the same function.
	deps.CapsFor = func(account, repo string) api.CapSet { return odb.EffectiveCaps(account, repo) }
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, deps); err != nil {
		t.Fatal(err)
	}
	// Capability enforcement wired exactly as cmd/quarry wires it
	// (RFC 0004 §4): the router refines each op's Auth floor with the
	// letters its surface needs.
	capsFor := func(account, repo string) api.CapSet { return odb.EffectiveCaps(account, repo) }
	apiRouter, err := api.RouterWithCaps(r, "0.0.0-harness", capsFor, verify)
	if err != nil {
		t.Fatal(err)
	}
	mux := http.NewServeMux()
	mux.Handle("/api/", apiRouter)
	// The web layer gets the same sessions plane production gives it —
	// without one, capability gates have nothing to resolve and every
	// page falls back to the Auth floor (a hole with a nice layout).
	// Fixture caps: these tests authenticate as writers and maintainers
	// and expect their writes to land. In production those accounts
	// hold caps (imported from fossil, or granted); here the categories
	// carry them, so every fixture repo behaves like one whose owner
	// gave signed-in people the write letters. TestCapsGateTheAPI is
	// where per-account caps are proven — this is the ambient default.
	// The fixture writers hold their letters individually; categories
	// stay stock so capability separation remains observable.
	for name := range repos {
		// dan is every fixture's maintainer: write letters plus
		// moderation and repo admin.
		if err := odb.SetCaps("dan", name, "kfw3C5aiz"); err != nil {
			t.Fatal(err)
		}
		// eve is the ordinary signed-in writer — deliberately WITHOUT
		// 5/a, because two fixtures assert she cannot moderate.
		if err := odb.SetCaps("eve", name, "kfw3Ciz"); err != nil {
			t.Fatal(err)
		}
	}
	// Caps without a sessions plane: these tests speak Basic auth, and
	// a sessions plane would mean 'this forge has a login page', which
	// redirects credential-less writes instead of challenging them.
	mux.Handle("/", web.HandlerWith(r, nil, nil,
		[]web.Option{web.WithCaps(harnessCaps{odb: odb})}, web.Verify(verify)))
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv
}

// harnessSessions mirrors cmd/quarry's session plane: Basic-auth
// identity plus the capability resolver the page gates read.
// harnessCaps is the capability resolver only — no sessions, no
// cookies: the harness speaks Basic auth like an API client.
type harnessCaps struct{ odb *oltp.DB }

func (h harnessCaps) CapsOf(account, repo string) web.CapSet {
	return h.odb.EffectiveCaps(account, repo)
}
func snapshot(t *testing.T, srv *httptest.Server, path string) []byte {
	t.Helper()
	resp, err := srv.Client().Get(srv.URL + path)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Fatalf("%s: status %d", path, resp.StatusCode)
	}
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatal(err)
	}
	return body
}

func TestDisposability(t *testing.T) {
	stores := map[string]string{
		"alpha": buildStore(t, "alphaseed"),
		"beta":  buildStore(t, "betaseed"),
	}
	sf := surfaces([]string{"alpha", "beta"})

	dataA := filepath.Join(t.TempDir(), "data")
	if err := os.MkdirAll(dataA, 0o755); err != nil {
		t.Fatal(err)
	}
	first := ingestAndServe(t, stores, dataA)

	// Per-repo check-in and file surfaces are discovered live; their
	// paths are content-addressed hashes, identical across rebuilds.
	for _, repo := range []string{"alpha", "beta"} {
		var tl struct {
			Entries []struct {
				Kind string `json:"kind"`
				Hash string `json:"hash"`
			} `json:"entries"`
		}
		if err := json.Unmarshal(snapshot(t, first, "/api/v1/r/"+repo+"/timeline?y=ci"), &tl); err != nil {
			t.Fatal(err)
		}
		if len(tl.Entries) == 0 {
			t.Fatalf("%s: no check-ins", repo)
		}
		for _, e := range tl.Entries {
			if e.Kind != "ci" {
				continue
			}
			sf = append(sf,
				surface{"/r/" + repo + "/checkin/" + e.Hash, semdiff.HTML},
				surface{"/r/" + repo + "/checkin/" + e.Hash + "/diff", semdiff.HTML},
				surface{"/api/v1/r/" + repo + "/checkin/" + e.Hash, semdiff.JSON})
		}
		var tip struct {
			Files []struct {
				Name string `json:"name"`
			} `json:"files"`
		}
		if err := json.Unmarshal(snapshot(t, first, "/api/v1/r/"+repo+"/checkin/"+tl.Entries[0].Hash), &tip); err != nil {
			t.Fatal(err)
		}
		for _, f := range tip.Files {
			sf = append(sf,
				surface{"/r/" + repo + "/file/" + tl.Entries[0].Hash + "/" + f.Name, semdiff.HTML},
				surface{"/api/v1/r/" + repo + "/file/" + tl.Entries[0].Hash + "/" + f.Name, semdiff.JSON})
		}
	}

	// Isolation: repo A's content is invisible from repo B's surfaces.
	var cross struct {
		Count int `json:"count"`
	}
	if err := json.Unmarshal(snapshot(t, first, "/api/v1/r/beta/search?q=alphaseed"), &cross); err != nil {
		t.Fatal(err)
	}
	if cross.Count != 0 {
		t.Fatalf("repo isolation broken: alpha content searchable from beta (%d hits)", cross.Count)
	}
	if err := json.Unmarshal(snapshot(t, first, "/api/v1/r/alpha/search?q=alphaseed"), &cross); err != nil {
		t.Fatal(err)
	}
	if cross.Count == 0 {
		t.Fatal("alpha content not searchable from alpha")
	}

	before := map[string][]byte{}
	for _, s := range sf {
		before[s.path] = snapshot(t, first, s.path)
	}
	first.Close()

	// The wipe (0001 §11 step 2): every derived byte goes — both
	// indexes, the event stream, the cursors.
	if err := os.RemoveAll(dataA); err != nil {
		t.Fatal(err)
	}

	dataB := filepath.Join(t.TempDir(), "data")
	if err := os.MkdirAll(dataB, 0o755); err != nil {
		t.Fatal(err)
	}
	second := ingestAndServe(t, stores, dataB)
	for _, s := range sf {
		after := snapshot(t, second, s.path)
		d, err := semdiff.Diff(s.kind, before[s.path], after)
		if err != nil {
			t.Fatalf("%s: %v", s.path, err)
		}
		if d != "" {
			t.Errorf("%s: semantic diff after wipe-and-reingest — hostage state:\n%s", s.path, d)
		}
	}
}

// TestSemanticNotByteEquality pins why the harness diffs semantically:
// a rendering-order change must pass, missing data must fail (0001 §11).
func TestSemanticNotByteEquality(t *testing.T) {
	d, err := semdiff.Diff(semdiff.JSON,
		[]byte(`{"b":1,"a":"2026-08-14T10:00:00+02:00"}`),
		[]byte(`{"a":"2026-08-14 08:00:00Z","b":1}`))
	if err != nil || d != "" {
		t.Fatalf("harmless drift flagged: %q %v", d, err)
	}
	d, err = semdiff.Diff(semdiff.JSON, []byte(`{"a":1,"b":2}`), []byte(`{"a":1}`))
	if err != nil || d == "" {
		t.Fatal("dropped data not flagged")
	}
	if !strings.Contains(d, "b") {
		t.Fatalf("diff should name the loss: %s", d)
	}
}