Skip to content
quarry Artifact [5b93933a94]
Login

Artifact [5b93933a94]

Artifact 5b93933a94ff98ce0607e4a7929b13c59a96459d0b3a46a7babaa25b6a8e4cd4:


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/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/web"
	"craftdesign.group/quarry/internal/xfer"
	"craftdesign.group/quarry/internal/zitcli"
)

// RFC 0007 §Testing: the matrix is the spec. alice owns trunk; bob
// holds `i` and owns nothing. Every row below is one line of the RFC's
// table, driven through the forge the way a person or a client would
// drive it — the forge's accept through the API, a clone's accept
// through the sync gate, a stock fossil client through the same gate.
//
// Three rows are proved elsewhere and named here so the matrix reads
// complete: the gate always sets its own header and strips a client's
// (xfer: TestGateAlwaysSetsItsOwnProtectHeader); a control artifact
// retagging a check-in onto trunk is refused (zit#151's own unit
// tests, over the three violation shapes); a stacked N+1 landing after
// N lands on trunk shows a disabled control, because MayLand is
// computed at read time against the CURRENT target and that is row 3.

const protectPW = "pw12345678"

type protectForge struct {
	srv   *httptest.Server
	odb   *oltp.DB
	store string
	code  string
	sync  func()
	wipe  func()
}

func newProtectForge(t *testing.T) *protectForge {
	t.Helper()
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	store := buildStore(t, "protectseed")
	dataDir := t.TempDir()
	ev, err := events.Start(filepath.Join(dataDir, "nats"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(ev.Close)
	odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { odb.Close() })

	code, err := xfer.ProjectCode(store)
	if err != nil {
		t.Fatal(err)
	}
	// Two accounts, both able to land in fossil's terms (`i`), and two
	// sync users with the same passwords, so the CLI rows authenticate
	// as the same people the API rows do.
	for _, name := range []string{"alice", "bob"} {
		if err := odb.AddAccount(name, protectPW, false); err != nil {
			t.Fatal(err)
		}
		if err := odb.SetCaps(name, "alpha", "kfw3C5aiz"); err != nil {
			t.Fatal(err)
		}
		if err := odb.SetSyncUser("alpha", name, xfer.SharedSecret(code, name, protectPW)); err != nil {
			t.Fatal(err)
		}
	}
	if err := odb.SetProtection("alpha", "trunk", []string{"alice"}); err != nil {
		t.Fatal(err)
	}

	f := &protectForge{odb: odb, store: store, code: code}
	var ix *index.Index
	var pipe *ingest.Pipeline
	open := func() {
		var err error
		ix, err = index.Open(filepath.Join(dataDir, "alpha.db"))
		if err != nil {
			t.Fatal(err)
		}
		cli := zitcli.New(store)
		pipe = ingest.NewPipeline(cli, ev, ix, "alpha", "harness")
	}
	open()
	t.Cleanup(func() { ix.Close() })

	deps := api.Deps{Version: "0.0.0-harness", Repos: api.NewRepos(nil), Votes: odb, Forum: odb,
		Protect: odb}
	cli := zitcli.New(store)
	put := func() {
		deps.Repos.Put("alpha", &api.Repo{Index: ix, Zit: cli, Sync: pipe.Sync,
			Leaves: func() (map[string]bool, error) { return cli.Leaves(context.Background()) }})
	}
	put()
	f.sync = func() {
		t.Helper()
		if _, err := pipe.Sync(); err != nil {
			t.Fatal(err)
		}
	}
	f.sync()
	// The §11 wipe: derived data thrown away and rebuilt from the store.
	f.wipe = func() {
		t.Helper()
		ix.Close()
		if err := os.Remove(filepath.Join(dataDir, "alpha.db")); err != nil {
			t.Fatal(err)
		}
		open()
		put()
		f.sync()
	}

	r := ops.NewRegistry()
	if err := api.RegisterAll(r, deps); err != nil {
		t.Fatal(err)
	}
	verify := func(repo, user, pass string, _ ops.Auth) api.Decision {
		if (user == "alice" || user == "bob") && pass == protectPW {
			return api.Allowed
		}
		return api.Denied
	}
	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)
	}
	// The sync gate, wired exactly as serve wires it: identity from the
	// shared secret, the rule from Refused, the child --fronted.
	fr := xfer.New()
	t.Cleanup(fr.Close)
	fr.Register("alpha", store)
	fr.SetAuth(odb)
	fr.SetProtect(odb.Refused)
	mux := http.NewServeMux()
	mux.Handle("/api/", apiRouter)
	mux.Handle("POST /r/{repo}", fr)
	mux.Handle("/", web.HandlerWith(r, nil, nil,
		[]web.Option{web.WithCaps(harnessCaps{odb: odb}), web.WithProtect(odb)}, web.Verify(verify)))
	f.srv = httptest.NewServer(mux)
	t.Cleanup(f.srv.Close)
	return f
}

func (f *protectForge) call(t *testing.T, user, method, path, body string) (int, string) {
	t.Helper()
	req, _ := http.NewRequest(method, f.srv.URL+path, strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.SetBasicAuth(user, protectPW)
	resp, err := f.srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	return resp.StatusCode, string(b)
}

type landingView struct {
	Landing struct {
		ID        string `json:"id"`
		State     string `json:"state"`
		SettledBy string `json:"settled_by"`
		ClaimedBy string `json:"claimed_by"`
	} `json:"landing"`
	CanAccept bool     `json:"canAccept"`
	MayLand   bool     `json:"mayLand"`
	Protected bool     `json:"protected"`
	Owners    []string `json:"owners"`
	Merge     struct {
		Checkin string `json:"checkin"`
	} `json:"merge"`
}

func (f *protectForge) landing(t *testing.T, user, id string) landingView {
	t.Helper()
	code, body := f.call(t, user, "GET", "/api/v1/r/alpha/landing/"+id[:12], "")
	if code != 200 {
		t.Fatalf("GET landing %s as %s: %d %s", id[:12], user, code, body)
	}
	var v landingView
	if err := json.Unmarshal([]byte(body), &v); err != nil {
		t.Fatal(err)
	}
	return v
}

// openLanding commits on a fresh branch in the store and proposes it.
func (f *protectForge) openLanding(t *testing.T, branch, file string) string {
	t.Helper()
	zrun(t, f.store, "update", "trunk")
	if err := os.WriteFile(filepath.Join(f.store, file), []byte(branch+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, f.store, "add", file)
	zrun(t, f.store, "commit", "-m", "work on "+branch, "--branch", branch)
	f.sync()
	code, body := f.call(t, "bob", "POST", "/api/v1/r/alpha/landing",
		`{"branch":"`+branch+`","target":"trunk","message":"land `+branch+`"}`)
	if code != 200 {
		t.Fatalf("open landing for %s: %d %s", branch, code, body)
	}
	var out struct {
		ID string `json:"id"`
	}
	if err := json.Unmarshal([]byte(body), &out); err != nil || out.ID == "" {
		t.Fatalf("no landing id: %v %s", err, body)
	}
	f.sync()
	return out.ID
}

func (f *protectForge) clone(t *testing.T) string {
	t.Helper()
	work := t.TempDir()
	dir := filepath.Join(work, "clone")
	zrun(t, work, "clone", f.srv.URL+"/r/alpha", dir, "--once")
	// A clone is a store; a checkout is what a person works in, and
	// what `landing accept` and `commit --branch` need.
	zrun(t, dir, "update", "trunk")
	return dir
}

// zsay runs zit and returns what it said, for the rows where the
// message is the assertion or the failure needs explaining.
func zsay(t *testing.T, dir string, args ...string) (string, error) {
	t.Helper()
	cmd := exec.Command("zit", args...)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	return string(out), err
}

func (f *protectForge) push(t *testing.T, dir, user string) (string, error) {
	t.Helper()
	cmd := exec.Command("zit", "push", f.srv.URL+"/r/alpha", "--user", user, "--password", protectPW)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	return string(out), err
}

// ── the matrix ──────────────────────────────────────────────────────

// Rows 1 and 3: the forge's own accept, non-owner then owner.
func TestProtectForgeAccept(t *testing.T) {
	f := newProtectForge(t)
	id := f.openLanding(t, "feat-a", "a.txt")

	v := f.landing(t, "bob", id)
	if !v.CanAccept || v.MayLand || !v.Protected || strings.Join(v.Owners, ",") != "alice" {
		t.Fatalf("bob's view: %+v — want CanAccept, not MayLand, protected by alice", v)
	}
	if code, body := f.call(t, "bob", "POST", "/api/v1/r/alpha/landing/"+id[:12]+"/accept", `{}`); code != http.StatusForbidden {
		t.Fatalf("non-owner accept answered %d %s, want 403", code, body)
	} else if !strings.Contains(body, "alice") {
		t.Errorf("the refusal does not name the owners: %s", body)
	}
	f.sync()
	if v := f.landing(t, "bob", id); v.Landing.State != "open" {
		t.Fatalf("record is %q after a refused accept, want open", v.Landing.State)
	}
	if tl := zout(t, f.store, "timeline", "-n", "3"); strings.Contains(tl, "land feat-a") {
		t.Fatalf("a refused accept wrote a merge:\n%s", tl)
	}

	// The page's disabled control with the reason is proved in the web
	// package, where a viewer has an identity: this harness has no
	// sessions plane, so its pages see everyone as anonymous.
	if v := f.landing(t, "alice", id); !v.MayLand {
		t.Fatalf("the owner may not land: %+v", v)
	}
	if code, body := f.call(t, "alice", "POST", "/api/v1/r/alpha/landing/"+id[:12]+"/accept", `{}`); code != 200 {
		t.Fatalf("owner accept: %d %s", code, body)
	}
	f.sync()
	v = f.landing(t, "alice", id)
	if v.Landing.State != "accepted" || v.Merge.Checkin == "" {
		t.Fatalf("after the owner's accept: %+v, want accepted with a merge", v)
	}
	if tl := zout(t, f.store, "timeline", "-n", "3"); !strings.Contains(tl, "trunk") {
		t.Fatalf("the merge is not on trunk:\n%s", tl)
	}

	// Row: merged, then the branch continues. The tip is no longer an
	// ancestor of trunk's tip, and the record must not un-settle.
	zrun(t, f.store, "update", "feat-a")
	if err := os.WriteFile(filepath.Join(f.store, "a2.txt"), []byte("more\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, f.store, "add", "a2.txt")
	zrun(t, f.store, "commit", "-m", "feat-a continues")
	f.sync()
	if v := f.landing(t, "alice", id); v.Landing.State != "accepted" {
		t.Errorf("continuing the branch past its merge un-settled the record: %q", v.Landing.State)
	}
}

// Rows 2 and 4: accept on a clone, then push. The gate turns the
// pusher into a rule and the child refuses the whole round.
func TestProtectCLIAcceptAndPush(t *testing.T) {
	f := newProtectForge(t)
	id := f.openLanding(t, "feat-b", "b.txt")

	// bob: accept locally (a merge onto trunk in the clone), push.
	bob := f.clone(t)
	if out, err := zsay(t, bob, "landing", "accept", id[:12], "--user", "bob", "-m", "bob lands it"); err != nil {
		t.Fatalf("zit landing accept in a clone: %v\n%s", err, out)
	}
	out, err := f.push(t, bob, "bob")
	if err == nil {
		t.Fatalf("non-owner's pushed accept was admitted:\n%s", out)
	}
	if !strings.Contains(out, "trunk") {
		t.Errorf("the refusal does not name the branch:\n%s", out)
	}
	f.sync()
	if v := f.landing(t, "bob", id); v.Landing.State != "open" {
		t.Fatalf("record is %q after a refused push, want open — the whole round must be refused", v.Landing.State)
	}
	if tl := zout(t, f.store, "timeline", "-n", "3"); strings.Contains(tl, "bob lands it") {
		t.Fatalf("part of the refused round reached the store:\n%s", tl)
	}

	// alice: the same, admitted — and with no reply needed, the merge
	// alone settles the record and names her as approver.
	alice := f.clone(t)
	zrun(t, alice, "update", "trunk")
	zrun(t, alice, "merge", "feat-b")
	zrun(t, alice, "commit", "-m", "alice merges by hand")
	if out, err := f.push(t, alice, "alice"); err != nil {
		t.Fatalf("owner's push refused: %v\n%s", err, out)
	}
	f.sync()
	v := f.landing(t, "alice", id)
	if v.Landing.State != "accepted" || v.Landing.SettledBy != "" || v.Landing.ClaimedBy != "" {
		t.Fatalf("after a by-hand merge with no reply: %+v, want accepted, nothing posted", v)
	}
}

// Rows 7 and 8: what protection does NOT touch. Branching off trunk is
// not joining it; an unprotected branch is anyone's.
func TestProtectLeavesOtherWritesAlone(t *testing.T) {
	f := newProtectForge(t)
	bob := f.clone(t)
	if err := os.WriteFile(filepath.Join(bob, "side.txt"), []byte("off trunk\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if out, err := zsay(t, bob, "add", "side.txt"); err != nil {
		t.Fatalf("add in clone: %v\n%s", err, out)
	}
	if out, err := zsay(t, bob, "commit", "-m", "bob branches off trunk", "--branch", "side"); err != nil {
		t.Fatalf("commit --branch in clone: %v\n%s", err, out)
	}
	if out, err := f.push(t, bob, "bob"); err != nil {
		t.Fatalf("branching OFF a protected branch was refused: %v\n%s", err, out)
	}
	if err := os.WriteFile(filepath.Join(bob, "side2.txt"), []byte("more\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, bob, "add", "side2.txt")
	zrun(t, bob, "commit", "-m", "bob continues side")
	if out, err := f.push(t, bob, "bob"); err != nil {
		t.Fatalf("a push to an unprotected branch was refused: %v\n%s", err, out)
	}
	f.sync()
	if tl := zout(t, f.store, "timeline", "-n", "4"); !strings.Contains(tl, "bob continues side") {
		t.Fatalf("bob's side work did not land:\n%s", tl)
	}
}

// Row 5: a stock fossil client pushing a merge to trunk. The same
// gate, the same rule, and a refusal the client renders.
func TestProtectRefusesStockFossilMerge(t *testing.T) {
	f := newProtectForge(t)
	if _, err := exec.LookPath("fossil"); err != nil {
		t.Skip("fossil not on PATH")
	}
	f.openLanding(t, "feat-c", "c.txt")
	frun := func(dir string, args ...string) (string, error) {
		cmd := exec.Command("fossil", args...)
		cmd.Dir = dir
		out, err := cmd.CombinedOutput()
		return string(out), err
	}
	work := t.TempDir()
	if out, err := frun(work, "clone", f.srv.URL+"/r/alpha", "c.fossil"); err != nil {
		t.Fatalf("fossil clone: %v\n%s", err, out)
	}
	co := filepath.Join(work, "co")
	if err := os.MkdirAll(co, 0o755); err != nil {
		t.Fatal(err)
	}
	if out, err := frun(co, "open", filepath.Join(work, "c.fossil")); err != nil {
		t.Fatalf("fossil open: %v\n%s", err, out)
	}
	frun(co, "update", "trunk")
	if out, err := frun(co, "merge", "feat-c"); err != nil {
		t.Fatalf("fossil merge: %v\n%s", err, out)
	}
	if out, err := frun(co, "commit", "-m", "fossil merges feat-c", "--no-warnings", "--user-override", "bob"); err != nil {
		t.Fatalf("fossil commit: %v\n%s", err, out)
	}
	out, _ := frun(co, "push", strings.Replace(f.srv.URL, "http://", "http://bob:"+protectPW+"@", 1)+"/r/alpha", "--once")
	if !strings.Contains(out, "trunk") {
		t.Fatalf("stock fossil push of a merge to trunk was not refused with the branch named:\n%s", out)
	}
	if tl := zout(t, f.store, "timeline", "-n", "3"); strings.Contains(tl, "fossil merges feat-c") {
		t.Fatalf("the refused fossil merge reached the store:\n%s", tl)
	}
}

// Row 9: a forged transition reply. Anyone who may speak may post the
// marker; with no merge behind it, it is a claim and the page says so.
func TestProtectForgedAcceptIsAClaim(t *testing.T) {
	f := newProtectForge(t)
	id := f.openLanding(t, "feat-d", "d.txt")
	if code, body := f.call(t, "bob", "POST", "/api/v1/r/alpha/landing/"+id[:12]+"/comment",
		`{"message":"zit-landing-state: accepted\n\nI say so"}`); code != 200 {
		t.Fatalf("comment: %d %s", code, body)
	}
	f.sync()
	v := f.landing(t, "bob", id)
	if v.Landing.State != "open" {
		t.Fatalf("a forged reply settled the record: state %q", v.Landing.State)
	}
	if v.Landing.ClaimedBy == "" {
		t.Errorf("the forged reply is not recorded as a claim: %+v", v)
	}
}

// Compatibility and disposability. A clone is the same clone with the
// row present or absent; the record renders the same before and after
// the row is added, and before and after the §11 wipe.
func TestProtectLeavesNoTraceAndSurvivesTheWipe(t *testing.T) {
	f := newProtectForge(t)
	id := f.openLanding(t, "feat-e", "e.txt")
	if code, body := f.call(t, "alice", "POST", "/api/v1/r/alpha/landing/"+id[:12]+"/accept", `{}`); code != 200 {
		t.Fatalf("owner accept: %d %s", code, body)
	}
	f.sync()
	state := func() string {
		v := f.landing(t, "alice", id)
		return v.Landing.State + "|" + v.Landing.SettledBy + "|" + v.Landing.ClaimedBy + "|" + v.Merge.Checkin
	}
	artifacts := func() string { return zout(t, f.clone(t), "timeline", "-n", "50") }

	withRow, withRowClone := state(), artifacts()
	if err := f.odb.SetProtection("alpha", "trunk", nil); err != nil {
		t.Fatal(err)
	}
	f.sync()
	if got := state(); got != withRow {
		t.Errorf("deleting the protection row changed how the record renders:\n %s\n %s", withRow, got)
	}
	if got := artifacts(); got != withRowClone {
		t.Errorf("a clone differs with the row absent — protection left a trace in the repository")
	}
	if err := f.odb.SetProtection("alpha", "trunk", []string{"alice"}); err != nil {
		t.Fatal(err)
	}
	f.wipe()
	if got := state(); got != withRow {
		t.Errorf("the §11 wipe changed how the record renders:\n %s\n %s", withRow, got)
	}
}