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"`
Stack struct {
Segments []struct {
ID string `json:"id"`
Landable bool `json:"landable"`
} `json:"segments"`
} `json:"stack"`
}
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)
}
}
// RFC 0006 build order, phase 4: a real strata through the forge with a
// real zit. Two landings stacked; the queue counts one decision; accept
// the base as the owner and zit#144 retargets the next onto trunk, so
// it becomes the landable end and the queue still counts one.
func TestStrataWalksUpAsTheBaseLands(t *testing.T) {
f := newProtectForge(t)
a := f.openLanding(t, "st-a", "sa.txt")
// The second branch off the first, proposed onto it.
zrun(t, f.store, "update", "st-a")
if err := os.WriteFile(filepath.Join(f.store, "sb.txt"), []byte("b\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, f.store, "add", "sb.txt")
zrun(t, f.store, "commit", "-m", "work on st-b", "--branch", "st-b")
f.sync()
code, body := f.call(t, "bob", "POST", "/api/v1/r/alpha/landing",
`{"branch":"st-b","target":"st-a","message":"land st-b on st-a"}`)
if code != 200 {
t.Fatalf("open stacked landing: %d %s", code, body)
}
var b struct {
ID string `json:"id"`
}
if err := json.Unmarshal([]byte(body), &b); err != nil {
t.Fatal(err)
}
f.sync()
list := func() (open int, stacks int) {
code, body := f.call(t, "alice", "GET", "/api/v1/r/alpha/landing", "")
if code != 200 {
t.Fatalf("list: %d %s", code, body)
}
var out struct {
Open int `json:"open"`
Landings []struct {
Stack string `json:"stack"`
Position int `json:"position"`
} `json:"landings"`
}
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatal(err)
}
for _, l := range out.Landings {
if l.Stack != "" {
stacks++
}
}
return out.Open, stacks
}
if open, stacked := list(); open != 1 || stacked != 2 {
t.Fatalf("two stacked landings: open=%d stacked=%d, want 1 decision over 2 rungs", open, stacked)
}
if v := f.landing(t, "alice", b.ID); v.Landing.State != "open" || len(v.Stack.Segments) != 2 {
t.Fatalf("top rung before the base lands: %+v", v)
}
if code, body := f.call(t, "alice", "POST", "/api/v1/r/alpha/landing/"+b.ID[:12]+"/accept", `{}`); code == 200 {
t.Fatalf("accepting the TOP rung succeeded: %s", body)
}
if code, body := f.call(t, "alice", "POST", "/api/v1/r/alpha/landing/"+a[:12]+"/accept", `{}`); code != 200 {
t.Fatalf("accepting the base: %d %s", code, body)
}
f.sync()
if v := f.landing(t, "alice", a); v.Landing.State != "accepted" {
t.Fatalf("base after accept: %q", v.Landing.State)
}
// zit#144 retargeted st-b onto trunk: it is now a standalone,
// landable landing, and the queue still holds one decision.
if open, stacked := list(); open != 1 || stacked != 0 {
t.Fatalf("after the base landed: open=%d stacked=%d, want 1 decision and no stack", open, stacked)
}
v := f.landing(t, "alice", b.ID)
if v.Landing.State != "open" || len(v.Stack.Segments) != 0 || !v.MayLand {
t.Fatalf("top rung after the base landed: %+v, want open, no stack, landable", v)
}
}
// Land the strata: one op, one accept per rung bottom-up, zit retargeting
// between them. The first rung that will not merge stops the walk; what
// landed stays landed and the rest is still a strata.
func TestLandTheStrata(t *testing.T) {
f := newProtectForge(t)
// Three rungs: st-a on trunk, st-b on st-a, st-c on st-b.
a := f.openLanding(t, "st-a", "sa.txt")
stackOn := func(branch, on, file string) string {
t.Helper()
zrun(t, f.store, "update", on)
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":"`+on+`","message":"land `+branch+`"}`)
if code != 200 {
t.Fatalf("open %s: %d %s", branch, code, body)
}
var out struct {
ID string `json:"id"`
}
json.Unmarshal([]byte(body), &out)
f.sync()
return out.ID
}
b := stackOn("st-b", "st-a", "sb.txt")
c := stackOn("st-c", "st-b", "sc.txt")
land := func(user, id string) (int, map[string]any) {
t.Helper()
code, body := f.call(t, user, "POST", "/api/v1/r/alpha/landing/"+id[:12]+"/land", `{}`)
var out map[string]any
json.Unmarshal([]byte(body), &out)
return code, out
}
// A non-owner may not land any of it: trunk is protected, and the
// base is what lands first.
if code, out := land("bob", c); code != 200 || out["stopped"] == nil || len(out["landed"].([]any)) != 0 {
t.Fatalf("non-owner landing the strata: %d %v — want a stop at the base with nothing landed", code, out)
}
f.sync()
// The owner lands all three in one op.
code, out := land("alice", c)
if code != 200 {
t.Fatalf("land the strata: %d %v", code, out)
}
if landed := out["landed"].([]any); len(landed) != 3 || out["stopped"] != nil {
t.Fatalf("landed %v stopped %v, want all three and no stop", landed, out["stopped"])
}
f.sync()
for _, id := range []string{a, b, c} {
if v := f.landing(t, "alice", id); v.Landing.State != "accepted" {
t.Errorf("%s: %q after landing the strata", id[:12], v.Landing.State)
}
}
if tl := zout(t, f.store, "timeline", "-n", "8"); !strings.Contains(tl, "trunk") {
t.Fatalf("nothing on trunk:\n%s", tl)
}
}
// The mid-stack failure mode, answered: rung 2 conflicts with trunk, so
// the walk lands rung 1, stops at 2 with the reason, and rung 3 is
// still stacked on 2 — a shorter strata, not a broken one.
func TestLandTheStrataStopsAtTheFirstConflict(t *testing.T) {
f := newProtectForge(t)
a := f.openLanding(t, "cf-a", "ca.txt")
zrun(t, f.store, "update", "cf-a")
if err := os.WriteFile(filepath.Join(f.store, "shared.txt"), []byte("rung two's line\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, f.store, "add", "shared.txt")
zrun(t, f.store, "commit", "-m", "work on cf-b", "--branch", "cf-b")
f.sync()
code, body := f.call(t, "bob", "POST", "/api/v1/r/alpha/landing", `{"branch":"cf-b","target":"cf-a","message":"land cf-b"}`)
if code != 200 {
t.Fatalf("open cf-b: %d %s", code, body)
}
var bb struct {
ID string `json:"id"`
}
json.Unmarshal([]byte(body), &bb)
// trunk moves under it with a different shared.txt.
zrun(t, f.store, "update", "trunk")
if err := os.WriteFile(filepath.Join(f.store, "shared.txt"), []byte("trunk's line\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, f.store, "add", "shared.txt")
zrun(t, f.store, "commit", "-m", "trunk moves")
f.sync()
code, body = f.call(t, "alice", "POST", "/api/v1/r/alpha/landing/"+bb.ID[:12]+"/land", `{}`)
if code != 200 {
t.Fatalf("land: %d %s", code, body)
}
var out struct {
Landed []string `json:"landed"`
Stopped string `json:"stopped"`
Reason string `json:"reason"`
}
json.Unmarshal([]byte(body), &out)
if len(out.Landed) != 1 || out.Landed[0] != a {
t.Fatalf("landed %v, want just the base", out.Landed)
}
if out.Stopped != bb.ID || out.Reason == "" {
t.Fatalf("stopped=%q reason=%q, want the conflicting rung with a reason", out.Stopped, out.Reason)
}
f.sync()
if v := f.landing(t, "alice", a); v.Landing.State != "accepted" {
t.Errorf("the base did not stay landed: %q", v.Landing.State)
}
if v := f.landing(t, "alice", bb.ID); v.Landing.State != "open" {
t.Errorf("the conflicting rung was settled: %q", v.Landing.State)
}
}
// zit#152 through the forge: the target moves, the page's update brings
// it into the branch on the store, the record reads clean and the reply
// is lifted as an event; a conflicting update is refused and nothing is
// written. Writes to the PROPOSED branch, so bob — who holds i but owns
// nothing on trunk — may do it.
func TestUpdateBringsTheTargetIn(t *testing.T) {
f := newProtectForge(t)
id := f.openLanding(t, "up-a", "ua.txt")
// trunk moves on an unrelated file.
zrun(t, f.store, "update", "trunk")
if err := os.WriteFile(filepath.Join(f.store, "elsewhere.txt"), []byte("moved\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, f.store, "add", "elsewhere.txt")
zrun(t, f.store, "commit", "-m", "trunk moves")
f.sync()
code, body := f.call(t, "bob", "POST", "/api/v1/r/alpha/landing/"+id[:12]+"/update", `{"message":"bring trunk in"}`)
if code != 200 {
t.Fatalf("update: %d %s", code, body)
}
f.sync()
code, body = f.call(t, "bob", "GET", "/api/v1/r/alpha/landing/"+id[:12], "")
if code != 200 {
t.Fatal(body)
}
var v struct {
Mergeable string `json:"mergeable"`
Posts []struct {
Updated string `json:"updated"`
} `json:"posts"`
}
json.Unmarshal([]byte(body), &v)
if v.Mergeable != "clean" {
t.Errorf("after update the record reads %q, want clean", v.Mergeable)
}
lifted := false
for _, p := range v.Posts {
if p.Updated != "" {
lifted = true
}
}
if !lifted {
t.Error("the update reply is not lifted as an event")
}
if tl := zout(t, f.store, "timeline", "-n", "3"); !strings.Contains(tl, "up-a") {
t.Fatalf("no merge on the branch:\n%s", tl)
}
// Now a real conflict: both sides edit the same file.
zrun(t, f.store, "update", "up-a")
if err := os.WriteFile(filepath.Join(f.store, "same.txt"), []byte("branch\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, f.store, "add", "same.txt")
zrun(t, f.store, "commit", "-m", "branch edits same")
zrun(t, f.store, "update", "trunk")
if err := os.WriteFile(filepath.Join(f.store, "same.txt"), []byte("trunk\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, f.store, "add", "same.txt")
zrun(t, f.store, "commit", "-m", "trunk edits same")
f.sync()
before := zout(t, f.store, "timeline", "-n", "1")
code, body = f.call(t, "bob", "POST", "/api/v1/r/alpha/landing/"+id[:12]+"/update", `{}`)
if code == 200 {
t.Fatalf("a conflicting update was accepted: %s", body)
}
if !strings.Contains(body, "same.txt") {
t.Errorf("the refusal does not name the path: %s", body)
}
if after := zout(t, f.store, "timeline", "-n", "1"); after != before {
t.Error("a refused update wrote something")
}
}