Skip to content
quarry Artifact [538fdc44d3]
Login

Artifact [538fdc44d3]

Artifact 538fdc44d378669672d8da01b091e843319fe8ebd31b3b36397ffd3216ed722b:


package xfer

import (
	"net"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
)

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

// TestCloneAndPushThroughFronter is the 0001 §7 acceptance: `zit clone
// <quarry>/r/<repo>` works, and a push through the same URL lands
// artifacts in the fronted store.
func TestCloneAndPushThroughFronter(t *testing.T) {
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	origin := t.TempDir()
	zrun(t, origin, "init")
	if err := os.WriteFile(filepath.Join(origin, "README.md"), []byte("# fronted\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, origin, "add", "README.md")
	zrun(t, origin, "commit", "-m", "first")

	f := New()
	defer f.Close()
	f.Register("alpha", origin)
	mux := http.NewServeMux()
	mux.Handle("POST /r/{repo}", f)
	mux.Handle("POST /r/{repo}/xfer", f)
	srv := httptest.NewServer(mux)
	defer srv.Close()

	// Clone through quarry: the repo URL is used verbatim by zit.
	work := t.TempDir()
	clone := filepath.Join(work, "clone")
	zrun(t, work, "clone", srv.URL+"/r/alpha", clone)
	if tl := zrun(t, clone, "timeline", "-n", "3"); !strings.Contains(tl, "first") {
		t.Fatalf("clone missing history:\n%s", tl)
	}

	// Push back through the same URL (saved as the clone's remote).
	if err := os.WriteFile(filepath.Join(clone, "NOTES.md"), []byte("pushed\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, clone, "add", "NOTES.md")
	zrun(t, clone, "commit", "-m", "pushed through quarry")
	zrun(t, clone, "push")
	if tl := zrun(t, origin, "timeline", "-n", "3"); !strings.Contains(tl, "pushed through quarry") {
		t.Fatalf("push did not land in origin:\n%s", tl)
	}

	// The /xfer alias answers too.
	zrun(t, work, "clone", srv.URL+"/r/alpha/xfer", filepath.Join(work, "clone2"))

	// Unknown repos 404 rather than 502.
	resp, err := http.Post(srv.URL+"/r/nope", "application/x-fossil", strings.NewReader(""))
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("unknown repo: status %d", resp.StatusCode)
	}
}

// TestBackendRestartAfterDeath: a dead child is restarted on the next
// request instead of 502ing forever.
func TestBackendRestartAfterDeath(t *testing.T) {
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	origin := t.TempDir()
	zrun(t, origin, "init")

	f := New()
	defer f.Close()
	f.Register("alpha", origin)
	mux := http.NewServeMux()
	mux.Handle("POST /r/{repo}", f)
	srv := httptest.NewServer(mux)
	defer srv.Close()

	work := t.TempDir()
	zrun(t, work, "clone", srv.URL+"/r/alpha", filepath.Join(work, "c1"))

	// Kill the child behind its back.
	f.mu.Lock()
	b := f.repos["alpha"]
	b.cmd.Process.Kill()
	f.mu.Unlock()

	zrun(t, work, "clone", srv.URL+"/r/alpha", filepath.Join(work, "c2"))
}

type mapAuth struct{ users map[string]string } // user -> secret, one repo

func (m *mapAuth) Gated(string) bool { return len(m.users) > 0 }

// Every account this fake knows may write; the refusal cases that turn
// on the LETTER are exercised where the letter is the subject.
func (m *mapAuth) MayWrite(_, user string) bool { _, ok := m.users[user]; return ok }
func (m *mapAuth) Secret(_, user string) (string, bool) {
	s, ok := m.users[user]
	return s, ok
}

// TestPushGating is 0001 §7 v1 auth: with a sync user configured,
// anonymous pushes bounce with an error card and pulls stay anonymous.
// The authenticated-push leg drives the fossil client: it sends a
// credential on every round per sync RFC §6.5, where the zit client
// stops after round 1 (zit#74) — until that lands, zit pushes to a
// gated repo fail closed.
func TestPushGating(t *testing.T) {
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	origin := t.TempDir()
	zrun(t, origin, "init")
	if err := os.WriteFile(filepath.Join(origin, "README.md"), []byte("# gated\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, origin, "add", "README.md")
	zrun(t, origin, "commit", "-m", "first")

	code, err := ProjectCode(origin)
	if err != nil {
		t.Fatal(err)
	}
	if len(code) < 40 {
		t.Fatalf("suspicious project code %q", code)
	}

	f := New()
	defer f.Close()
	f.Register("alpha", origin)
	f.SetAuth(&mapAuth{users: map[string]string{
		"dan": SharedSecret(code, "dan", "hunter2"),
	}})
	mux := http.NewServeMux()
	mux.Handle("POST /r/{repo}", f)
	srv := httptest.NewServer(mux)
	defer srv.Close()

	// Pull stays anonymous (--once so the commit below cannot autosync).
	work := t.TempDir()
	clone := filepath.Join(work, "clone")
	zrun(t, work, "clone", srv.URL+"/r/alpha", clone, "--once")
	if err := os.WriteFile(filepath.Join(clone, "NOTES.md"), []byte("gated push\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, clone, "add", "NOTES.md")
	zrun(t, clone, "commit", "-m", "wants in")

	// Anonymous push: refused, and nothing lands.
	cmd := exec.Command("zit", "push", srv.URL+"/r/alpha")
	cmd.Dir = clone
	if out, err := cmd.CombinedOutput(); err == nil {
		t.Fatalf("anonymous push succeeded against a gated repo:\n%s", out)
	}
	if tl := zrun(t, origin, "timeline", "-n", "2"); strings.Contains(tl, "wants in") {
		t.Fatalf("refused push still landed:\n%s", tl)
	}

	if _, err := exec.LookPath("fossil"); err != nil {
		t.Skip("fossil not on PATH for the authenticated-push leg")
	}
	frun := func(dir string, args ...string) (string, error) {
		cmd := exec.Command("fossil", args...)
		cmd.Dir = dir
		out, err := cmd.CombinedOutput()
		return string(out), err
	}

	// Wrong password: refused.
	fwork := t.TempDir()
	if out, err := frun(fwork, "clone", srv.URL+"/r/alpha", "bad.fossil"); err != nil {
		t.Fatalf("fossil clone (pull is anonymous) should succeed: %v\n%s", err, out)
	}
	co := filepath.Join(fwork, "co")
	if err := os.MkdirAll(co, 0o755); err != nil {
		t.Fatal(err)
	}
	if out, err := frun(co, "open", filepath.Join(fwork, "bad.fossil")); err != nil {
		t.Fatalf("fossil open: %v\n%s", err, out)
	}
	if err := os.WriteFile(filepath.Join(co, "F.md"), []byte("fossil push\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	frun(co, "add", "F.md")
	if out, err := frun(co, "commit", "-m", "fossil wants in", "--no-warnings"); err != nil && !strings.Contains(out, "not authorized") {
		t.Fatalf("fossil commit: %v\n%s", err, out)
	}
	if out, _ := frun(co, "push", strings.Replace(srv.URL, "http://", "http://dan:wrong@", 1)+"/r/alpha", "--once"); !strings.Contains(strings.ToLower(out), "not authorized") {
		t.Fatalf("wrong-password fossil push not refused:\n%s", out)
	}
	if tl := zrun(t, origin, "timeline", "-n", "2"); strings.Contains(tl, "fossil wants in") {
		t.Fatalf("refused fossil push landed:\n%s", tl)
	}

	// Right credentials: lands.
	if out, err := frun(co, "push", strings.Replace(srv.URL, "http://", "http://dan:hunter2@", 1)+"/r/alpha", "--once"); err != nil {
		t.Fatalf("authenticated fossil push failed: %v\n%s", err, out)
	}
	if tl := zrun(t, origin, "timeline", "-n", "2"); !strings.Contains(tl, "fossil wants in") {
		t.Fatalf("authenticated fossil push missing from origin:\n%s", tl)
	}
}

// ProjectCode must work on a BARE store — a directory holding only the
// .zit file, with no checkout. That is the whole point of reading it
// through `zit stat -R` (zit#85) instead of handshaking against a
// transient `zit serve`, which is checkout-only. quarry's read plane is
// already -R-only, so this is the last thing that stood between it and
// serving a bare store.
func TestProjectCodeOnBareStore(t *testing.T) {
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	origin := t.TempDir()
	zrun(t, origin, "init")
	if err := os.WriteFile(filepath.Join(origin, "a.txt"), []byte("a\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, origin, "add", "a.txt")
	zrun(t, origin, "commit", "-m", "first")

	want, err := ProjectCode(origin)
	if err != nil {
		t.Fatal(err)
	}

	// Copy ONLY the store: no .zit-checkout, no .zit-cache, no files.
	bare := t.TempDir()
	blob, err := os.ReadFile(filepath.Join(origin, ".zit"))
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(bare, ".zit"), blob, 0o644); err != nil {
		t.Fatal(err)
	}
	entries, err := os.ReadDir(bare)
	if err != nil {
		t.Fatal(err)
	}
	if len(entries) != 1 || entries[0].Name() != ".zit" {
		t.Fatalf("bare store is not bare: %v", entries)
	}

	got, err := ProjectCode(bare)
	if err != nil {
		t.Fatalf("bare store: %v", err)
	}
	if got != want {
		t.Fatalf("bare store reports %q, checkout reports %q", got, want)
	}
}

// fakeChild stands in for a zit serve child and records what the gate
// sent it. The gate believes a backend is alive when its process has
// not exited and its port answers, so a sleeping process fronts a
// listener the test owns.
func fakeChild(t *testing.T, f *Fronter, name string) (*httptest.Server, *http.Header) {
	t.Helper()
	ln, err := net.Listen("tcp4", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	var got http.Header
	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got = r.Header.Clone()
		w.Header().Set("Content-Type", "application/x-fossil")
		w.Write([]byte("pragma ok\n"))
	}))
	srv.Listener = ln
	srv.Start()
	t.Cleanup(srv.Close)
	sleeper := exec.Command("sleep", "60")
	if err := sleeper.Start(); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { sleeper.Process.Kill() })
	f.mu.Lock()
	f.repos[name] = &backend{dir: t.TempDir(), port: ln.Addr().(*net.TCPAddr).Port, cmd: sleeper}
	f.mu.Unlock()
	return srv, &got
}

// RFC 0007 §Enforcement, requirements 1 and 2 on the gate: the protect
// header is on EVERY request the child sees — an empty value when
// nothing is refused, never an absent header — and a client-supplied
// header with the same name never survives the gate. zit#151 treats an
// absent header as no rule, so absence is the failure mode a gate bug
// would produce, and it must be impossible by construction.
func TestGateAlwaysSetsItsOwnProtectHeader(t *testing.T) {
	f := New()
	defer f.Close()
	_, seen := fakeChild(t, f, "alpha")
	mux := http.NewServeMux()
	mux.Handle("POST /r/{repo}", f)
	srv := httptest.NewServer(mux)
	defer srv.Close()

	send := func(clientHeader string) {
		t.Helper()
		req, _ := http.NewRequest("POST", srv.URL+"/r/alpha", strings.NewReader("pull\n"))
		req.Header.Set("Content-Type", "application/x-fossil")
		if clientHeader != "" {
			req.Header.Set(ProtectHeader, clientHeader)
		}
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		resp.Body.Close()
	}

	// No rule source at all: the header is still present, and empty.
	send("")
	if v, ok := (*seen)[http.CanonicalHeaderKey(ProtectHeader)]; !ok {
		t.Fatalf("no protect header reached the child; zit reads absence as no rule")
	} else if strings.Join(v, "") != "" {
		t.Errorf("header %q with no rule source, want empty", v)
	}

	// A client's header is not forwarded: the child sees the gate's
	// value, which with no rule source is empty.
	send("trunk,release-*")
	if v := (*seen).Get(ProtectHeader); v != "" {
		t.Fatalf("client-supplied protect header %q reached the child", v)
	}

	// With a rule source, the gate's computed value — and still not
	// the client's.
	f.SetProtect(func(repo, user string) ([]string, error) { return []string{"trunk", "release-*"}, nil })
	send("nothing")
	if v := (*seen).Get(ProtectHeader); v != "trunk,release-*" {
		t.Fatalf("child saw %q, want the gate's rule", v)
	}
}

// The push path end to end: a non-owner's merge onto trunk is refused
// by the child on the gate's rule, the whole round, with the branch
// named; an owner's identical push is admitted. The rule is the gate's
// (who pushed) and the decision is the child's (which branch), which
// is the split RFC 0007 chose.
func TestProtectedBranchRefusesNonOwnerPush(t *testing.T) {
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	origin := t.TempDir()
	zrun(t, origin, "init")
	if err := os.WriteFile(filepath.Join(origin, "README.md"), []byte("# protected\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, origin, "add", "README.md")
	zrun(t, origin, "commit", "-m", "first")
	code, err := ProjectCode(origin)
	if err != nil {
		t.Fatal(err)
	}
	f := New()
	defer f.Close()
	f.Register("alpha", origin)
	f.SetAuth(&mapAuth{users: map[string]string{
		"alice": SharedSecret(code, "alice", "pw-a"),
		"bob":   SharedSecret(code, "bob", "pw-b"),
	}})
	// alice owns trunk; bob does not.
	f.SetProtect(func(repo, user string) ([]string, error) {
		if user == "alice" {
			return nil, nil
		}
		return []string{"trunk"}, nil
	})
	mux := http.NewServeMux()
	mux.Handle("POST /r/{repo}", f)
	srv := httptest.NewServer(mux)
	defer srv.Close()

	work := t.TempDir()
	clone := filepath.Join(work, "clone")
	zrun(t, work, "clone", srv.URL+"/r/alpha", clone, "--once")
	if err := os.WriteFile(filepath.Join(clone, "ONTRUNK.md"), []byte("joins trunk\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	zrun(t, clone, "add", "ONTRUNK.md")
	zrun(t, clone, "commit", "-m", "bob wants trunk")

	push := func(user, pw string) (string, error) {
		cmd := exec.Command("zit", "push", srv.URL+"/r/alpha", "--user", user, "--password", pw)
		cmd.Dir = clone
		out, err := cmd.CombinedOutput()
		return string(out), err
	}
	out, err := push("bob", "pw-b")
	if err == nil {
		t.Fatalf("non-owner push to a protected branch succeeded:\n%s", out)
	}
	if !strings.Contains(out, "trunk") {
		t.Errorf("refusal does not name the branch:\n%s", out)
	}
	if tl := zrun(t, origin, "timeline", "-n", "3"); strings.Contains(tl, "bob wants trunk") {
		t.Fatalf("refused push still landed:\n%s", tl)
	}
	if out, err := push("alice", "pw-a"); err != nil {
		t.Fatalf("owner push refused: %v\n%s", err, out)
	}
	if tl := zrun(t, origin, "timeline", "-n", "3"); !strings.Contains(tl, "bob wants trunk") {
		t.Fatalf("owner push missing from origin:\n%s", tl)
	}
}