gitmote

gitmote / internal / githttp / receive_test.go  ·  history  ·  branches

27490 bytes · raw

package githttp

import (
	"context"
	"crypto/rand"
	"errors"
	"fmt"
	"log/slog"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"testing"

	"github.com/atmin/gitmote/internal/auth"
	"github.com/atmin/gitmote/internal/ci"
	"github.com/atmin/gitmote/internal/meta"
	"github.com/atmin/gitmote/internal/repo"
	"github.com/atmin/gitmote/internal/store"
)

var (
	hookOnce sync.Once
	hookPath string
	hookErr  error
)

// buildHook compiles the pre-receive hook binary once per test run and returns
// its path.
func buildHook(t *testing.T) string {
	t.Helper()
	hookOnce.Do(func() {
		dir, err := os.MkdirTemp("", "gitmote-hookbin-")
		if err != nil {
			hookErr = err
			return
		}
		out := filepath.Join(dir, "gitmote-hook")
		cmd := exec.Command("go", "build", "-o", out, "github.com/atmin/gitmote/cmd/gitmote-hook")
		if b, err := cmd.CombinedOutput(); err != nil {
			hookErr = fmt.Errorf("build hook: %v\n%s", err, b)
			return
		}
		hookPath = out
	})
	if hookErr != nil {
		t.Fatal(hookErr)
	}
	return hookPath
}

func openMeta(t *testing.T) *meta.Metadata {
	t.Helper()
	m, err := meta.Open(context.Background(), meta.Config{
		LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
	})
	if err != nil {
		t.Fatalf("meta.Open: %v", err)
	}
	t.Cleanup(func() { _ = m.Close() })
	return m
}

// newWriteServer wires meta + mem store + materializer + write coordinator
// behind a guarded handler on an httptest server.
func newWriteServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store, *Writer) {
	t.Helper()
	m := openMeta(t)
	s := store.NewMem()
	// A short socket dir: unix socket paths are limited to ~104 bytes, and
	// t.TempDir() embeds the (long) test name.
	sockDir, err := os.MkdirTemp("", "gm")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = os.RemoveAll(sockDir) })
	sock := filepath.Join(sockDir, "s.sock")
	writer, err := NewWriter(m, s, buildHook(t), sock, slog.New(slog.DiscardHandler))
	if err != nil {
		t.Fatalf("NewWriter: %v", err)
	}
	t.Cleanup(func() { _ = writer.Close() })

	h, err := New(Config{
		Materializer: repo.New(m, s, t.TempDir()),
		Authorizer:   auth.NewGuard(m),
		Writer:       writer,
		Logger:       slog.New(slog.DiscardHandler),
	})
	if err != nil {
		t.Fatalf("githttp.New: %v", err)
	}
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)
	return srv, m, s, writer
}

// newGatedServer is newWriteServer with a controllable writer-lease gate
// (backend Config.IsWritable), to exercise a follower's push refusal.
func newGatedServer(t *testing.T, isWritable func() bool) (*httptest.Server, *meta.Metadata, store.Store) {
	t.Helper()
	m := openMeta(t)
	s := store.NewMem()
	sockDir, err := os.MkdirTemp("", "gm")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = os.RemoveAll(sockDir) })
	writer, err := NewWriter(m, s, buildHook(t), filepath.Join(sockDir, "s.sock"), slog.New(slog.DiscardHandler))
	if err != nil {
		t.Fatalf("NewWriter: %v", err)
	}
	t.Cleanup(func() { _ = writer.Close() })
	h, err := New(Config{
		Materializer: repo.New(m, s, t.TempDir()),
		Authorizer:   auth.NewGuard(m),
		Writer:       writer,
		IsWritable:   isWritable,
		Logger:       slog.New(slog.DiscardHandler),
	})
	if err != nil {
		t.Fatalf("githttp.New: %v", err)
	}
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)
	return srv, m, s
}

// TestWriteGateFollowerRefusesPush exercises the writer-lease gate: a follower
// (IsWritable false) refuses receive-pack while still serving reads; promoting
// back to leader accepts the same push. Mirrors the deploy-handoff window where
// a new instance is up read-only before it acquires the lease (safety.md §1).
func TestWriteGateFollowerRefusesPush(t *testing.T) {
	ctx := context.Background()
	leader := true
	srv, m, _ := newGatedServer(t, func() bool { return leader })

	r, err := m.CreateRepo(ctx, repoName, "main")
	if err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	// Golden path: the leader accepts the first push.
	src := initCommit(t, "hello\n")
	head1 := git(t, src, "rev-parse", "HEAD")
	git(t, src, "push", remote, "main")
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head1 {
		t.Fatalf("after leader push, server main = %q, want %q", got, head1)
	}

	// Become a follower: reads still serve, pushes are refused, ref unchanged.
	leader = false
	dst := filepath.Join(t.TempDir(), "clone")
	git(t, "", "clone", remote, dst) // a read succeeds on a follower
	if got := git(t, dst, "rev-parse", "HEAD"); got != head1 {
		t.Errorf("follower clone HEAD = %q, want %q", got, head1)
	}

	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\nagain\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "second")
	head2 := git(t, src, "rev-parse", "HEAD")
	out, err := tryGit(src, "push", remote, "main")
	if err == nil {
		t.Fatalf("follower push succeeded, want rejection\n%s", out)
	}
	if !strings.Contains(out, "503") {
		t.Errorf("follower push error missing 503 signal:\n%s", out)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head1 {
		t.Errorf("follower push advanced ref to %q, want unchanged %q", got, head1)
	}

	// Promote back to leader: the same push now succeeds.
	leader = true
	git(t, src, "push", remote, "main")
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head2 {
		t.Errorf("after promotion, server main = %q, want %q", got, head2)
	}
}

// TestPushChunkedLargePack pushes a pack larger than git's default
// http.postBuffer (1 MiB) with the client at its defaults, so git sends the body
// with chunked transfer-encoding (no Content-Length). The server must buffer it
// to a length before the CGI hand-off; without that, git http-backend 400s.
func TestPushChunkedLargePack(t *testing.T) {
	ctx := context.Background()
	srv, m, s, _ := newWriteServer(t)

	r, err := m.CreateRepo(ctx, repoName, "main")
	if err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	// A 3 MiB incompressible blob → a pack well over the 1 MiB postBuffer, so the
	// client streams it chunked (the harness git uses default config: no override).
	src := t.TempDir()
	git(t, src, "init", "-b", "main", ".")
	big := make([]byte, 3<<20)
	if _, err := rand.Read(big); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(src, "big.bin"), big, 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "add", ".")
	git(t, src, "commit", "-m", "big")
	head := git(t, src, "rev-parse", "HEAD")

	git(t, src, "push", remote, "main") // fails (HTTP 400) before the server-side buffering

	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
		t.Fatalf("server main = %q, want %q", got, head)
	}
	objs, _ := s.List(ctx, repoName+"/objects/")
	if len(objs) == 0 {
		t.Fatal("no objects uploaded after chunked push")
	}
}

// noopTrigger is a ci.Trigger that does nothing — the write-path tests only care
// that runs/jobs are recorded, not that a runner starts.
type noopTrigger struct{}

func (noopTrigger) Trigger(context.Context, map[string]string) error { return nil }

// wireDispatcher points the writer's after-commit hook at a real ci.Dispatcher
// reading workflow config through a materializer over store s, mirroring the
// main.go wiring.
func wireDispatcher(t *testing.T, w *Writer, m *meta.Metadata, s store.Store) {
	t.Helper()
	d := ci.NewDispatcher(ci.Config{
		Runs:         m,
		Materializer: repo.New(m, s, t.TempDir()),
		Trigger:      noopTrigger{},
		Logger:       slog.New(slog.DiscardHandler),
	})
	w.AfterCommit = func(ctx context.Context, pusherID int64, commits []CommitInfo) {
		for _, c := range commits {
			d.Dispatch(ctx, ci.Event{
				RepoID:   c.RepoID,
				RepoName: c.RepoName,
				Ref:      c.Ref,
				OldSHA:   c.Old,
				NewSHA:   c.New,
				PusherID: pusherID,
			})
		}
	}
}

// initWorkflowCommit makes a source repo whose first commit carries a valid
// workflow, so a push through the dispatcher discovers work and enqueues a run.
func initWorkflowCommit(t *testing.T) string {
	t.Helper()
	src := t.TempDir()
	git(t, src, "init", "-b", "main", ".")
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Join(src, ".gitmote", "workflows"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(src, ".gitmote", "workflows", "ci.yml"),
		[]byte("name: CI\non: push\njobs:\n  build:\n    runs-on: ubuntu-latest\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "add", "-A")
	git(t, src, "commit", "-m", "commit")
	return src
}

// TestAfterCommitEnqueuesRun drives a real git push of a repo with a workflow
// and asserts the after-commit seam discovers it and records one queued run and
// job for the advanced branch tip.
func TestAfterCommitEnqueuesRun(t *testing.T) {
	ctx := context.Background()
	srv, m, s, writer := newWriteServer(t)
	wireDispatcher(t, writer, m, s)

	r, err := m.CreateRepo(ctx, repoName, "main")
	if err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	src := initWorkflowCommit(t)
	head := git(t, src, "rev-parse", "HEAD")
	git(t, src, "push", remote, "main")

	runs, err := m.ListRuns(ctx, r.ID, 0)
	if err != nil {
		t.Fatalf("ListRuns: %v", err)
	}
	if len(runs) != 1 {
		t.Fatalf("after push, runs = %d, want exactly 1", len(runs))
	}
	if runs[0].Ref != "refs/heads/main" || runs[0].SHA != head || runs[0].Status != meta.RunQueued {
		t.Errorf("run = %+v, want ref refs/heads/main sha %q queued", runs[0], head)
	}
	jobs, _ := m.ListJobs(ctx, runs[0].ID)
	if len(jobs) != 1 || jobs[0].Name != "ci.yml" {
		t.Errorf("jobs = %+v, want one job named ci.yml", jobs)
	}
}

// TestAfterCommitNoWorkflowNoRun confirms the discovery gate end-to-end: a real
// push of a repo without .gitmote/workflows records no run.
func TestAfterCommitNoWorkflowNoRun(t *testing.T) {
	ctx := context.Background()
	srv, m, s, writer := newWriteServer(t)
	wireDispatcher(t, writer, m, s)

	r, _ := m.CreateRepo(ctx, repoName, "main")
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	src := initCommit(t, "hello\n") // no workflow file
	git(t, src, "push", remote, "main")

	if runs, _ := m.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
		t.Errorf("runs = %d, want 0 (no workflows)", len(runs))
	}
}

// TestRejectedPushEnqueuesNoRun asserts a non-fast-forward push — refused at the
// CAS — never fires the after-commit seam, so no run is recorded for it.
func TestRejectedPushEnqueuesNoRun(t *testing.T) {
	ctx := context.Background()
	srv, m, s, writer := newWriteServer(t)
	wireDispatcher(t, writer, m, s)

	r, _ := m.CreateRepo(ctx, repoName, "main")
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	// Establish main with a workflow (one accepted push → one run).
	src := initWorkflowCommit(t)
	git(t, src, "push", remote, "main")

	// A stale clone diverges and pushes a non-fast-forward, which is rejected.
	stale := filepath.Join(t.TempDir(), "stale")
	git(t, "", "clone", remote, stale)
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "advance")
	git(t, src, "push", remote, "main")

	if err := os.WriteFile(filepath.Join(stale, "file.txt"), []byte("one\ndivergent\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, stale, "commit", "-am", "divergent")
	if out, err := tryGit(stale, "push", remote, "main"); err == nil {
		t.Fatalf("stale push succeeded, want rejection\n%s", out)
	}

	// Two accepted pushes → two runs; the rejected push adds none.
	runs, err := m.ListRuns(ctx, r.ID, 0)
	if err != nil {
		t.Fatalf("ListRuns: %v", err)
	}
	if len(runs) != 2 {
		t.Errorf("runs = %d, want 2 (rejected push enqueues none)", len(runs))
	}
}

// TestAfterCommitPanicLeavesPushGreen is the safety invariant: an after-commit
// hook that panics must not fail the push — the ref still advances and the
// client sees a green push (content-before-pointer applied to CI).
func TestAfterCommitPanicLeavesPushGreen(t *testing.T) {
	ctx := context.Background()
	srv, m, _, writer := newWriteServer(t)
	writer.AfterCommit = func(context.Context, int64, []CommitInfo) {
		panic("dispatch blew up")
	}

	r, _ := m.CreateRepo(ctx, repoName, "main")
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	src := initCommit(t, "hello\n")
	head := git(t, src, "rev-parse", "HEAD")
	if out, err := tryGit(src, "push", remote, "main"); err != nil {
		t.Fatalf("push failed despite panicking after-commit hook: %v\n%s", err, out)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
		t.Errorf("main = %q, want %q — ref must advance even when the hook panics", got, head)
	}
}

// tryGit runs a git command hermetically and returns its combined output and
// error without failing the test — for operations expected to be rejected.
func tryGit(dir string, args ...string) (string, error) {
	// Disable any credential helper so a token from an earlier authed push is not
	// cached and reused, keeping an "anonymous" request genuinely anonymous.
	cmd := exec.CommandContext(context.Background(), "git", append([]string{"-c", "credential.helper="}, args...)...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(),
		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
		"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
		"GIT_TERMINAL_PROMPT=0",
	)
	out, err := cmd.CombinedOutput()
	return string(out), err
}

// initCommit makes a fresh source repo with one commit on main and returns its
// dir and head sha.
func initCommit(t *testing.T, content string) string {
	t.Helper()
	src := t.TempDir()
	git(t, src, "init", "-b", "main", ".")
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "add", ".")
	git(t, src, "commit", "-m", "commit")
	return src
}

// serverRefSHA returns the sha meta holds for a ref, or "" if absent.
func serverRefSHA(t *testing.T, m *meta.Metadata, repoID int64, name string) string {
	t.Helper()
	refs, err := m.ListRefs(context.Background(), repoID)
	if err != nil {
		t.Fatalf("ListRefs: %v", err)
	}
	for _, r := range refs {
		if r.Name == name {
			return r.SHA
		}
	}
	return ""
}

func TestPushThenClone(t *testing.T) {
	ctx := context.Background()
	srv, m, s, _ := newWriteServer(t)

	r, err := m.CreateRepo(ctx, repoName, "main")
	if err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	// Golden path: push a new repo's first commits.
	src := initCommit(t, "hello\n")
	head := git(t, src, "rev-parse", "HEAD")
	git(t, src, "push", remote, "main")

	// The ref advanced in s3lite and the objects are durable in the store.
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
		t.Fatalf("server main = %q, want %q", got, head)
	}
	objs, _ := s.List(ctx, repoName+"/objects/")
	if len(objs) == 0 {
		t.Fatal("no objects uploaded to the store after push")
	}

	// A clone returns exactly what was pushed, and fsck is clean.
	dst := filepath.Join(t.TempDir(), "clone")
	git(t, "", "clone", remote, dst)
	if got := git(t, dst, "rev-parse", "HEAD"); got != head {
		t.Errorf("cloned HEAD = %q, want %q", got, head)
	}
	if data, _ := os.ReadFile(filepath.Join(dst, "file.txt")); string(data) != "hello\n" {
		t.Errorf("cloned file = %q, want \"hello\\n\"", data)
	}
	git(t, dst, "fsck", "--strict")

	// A follow-up fast-forward push is accepted.
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\nagain\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "second")
	head2 := git(t, src, "rev-parse", "HEAD")
	git(t, src, "push", remote, "main")
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head2 {
		t.Errorf("server main after ff = %q, want %q", got, head2)
	}
}

func TestNonFastForwardRejected(t *testing.T) {
	ctx := context.Background()
	srv, m, s, _ := newWriteServer(t)
	r, _ := m.CreateRepo(ctx, repoName, "main")
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	// Establish main.
	src := initCommit(t, "one\n")
	git(t, src, "push", remote, "main")
	base := git(t, src, "rev-parse", "HEAD")

	// A stale clone that still sees main at base.
	stale := filepath.Join(t.TempDir(), "stale")
	git(t, "", "clone", remote, stale)

	// Meanwhile main advances on the server.
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "advance")
	git(t, src, "push", remote, "main")
	advanced := git(t, src, "rev-parse", "HEAD")

	// The stale client commits on top of base and pushes — a non-fast-forward
	// against the advanced tip. It must be rejected, main unchanged.
	if err := os.WriteFile(filepath.Join(stale, "file.txt"), []byte("one\ndivergent\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, stale, "commit", "-am", "divergent")
	if out, err := tryGit(stale, "push", remote, "main"); err == nil {
		t.Fatalf("stale push succeeded, want rejection\n%s", out)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != advanced {
		t.Errorf("main after rejected push = %q, want %q (unchanged)", got, advanced)
	}
	_ = base
	_ = s
}

func TestConcurrentPushOneWins(t *testing.T) {
	ctx := context.Background()
	srv, m, _, _ := newWriteServer(t)
	r, _ := m.CreateRepo(ctx, repoName, "main")
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	// A base commit both racers build on.
	src := initCommit(t, "base\n")
	git(t, src, "push", remote, "main")

	// Two independent clones, each with its own new commit on top of base.
	dirs := [2]string{filepath.Join(t.TempDir(), "a"), filepath.Join(t.TempDir(), "b")}
	heads := [2]string{}
	for i, d := range dirs {
		git(t, "", "clone", remote, d)
		if err := os.WriteFile(filepath.Join(d, "file.txt"), []byte(fmt.Sprintf("base\nracer-%d\n", i)), 0o644); err != nil {
			t.Fatal(err)
		}
		git(t, d, "commit", "-am", "racer")
		heads[i] = git(t, d, "rev-parse", "HEAD")
	}

	// Push both concurrently; exactly one must win.
	var wg sync.WaitGroup
	errs := [2]error{}
	for i, d := range dirs {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, errs[i] = tryGit(d, "push", remote, "main")
		}()
	}
	wg.Wait()

	wins := 0
	winner := -1
	for i := range errs {
		if errs[i] == nil {
			wins++
			winner = i
		}
	}
	if wins != 1 {
		t.Fatalf("concurrent pushes: %d winners, want exactly 1 (errs: %v)", wins, errs)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != heads[winner] {
		t.Errorf("main = %q, want winner %q — no lost update", got, heads[winner])
	}
}

func TestAtomicMultiRefRollback(t *testing.T) {
	ctx := context.Background()
	srv, m, _, _ := newWriteServer(t)
	r, _ := m.CreateRepo(ctx, repoName, "main")
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	src := initCommit(t, "one\n")
	git(t, src, "push", remote, "main")

	// Stale clone still at the original tip.
	stale := filepath.Join(t.TempDir(), "stale")
	git(t, "", "clone", remote, stale)

	// Advance main on the server.
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "advance")
	git(t, src, "push", remote, "main")
	advanced := git(t, src, "rev-parse", "HEAD")

	// The stale client builds a new commit and a brand-new branch, then pushes
	// both atomically. The main update is a non-fast-forward (fails); with
	// --atomic the feature create must roll back too.
	if err := os.WriteFile(filepath.Join(stale, "file.txt"), []byte("one\ndivergent\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, stale, "commit", "-am", "divergent")
	git(t, stale, "branch", "feature")
	if out, err := tryGit(stale, "push", "--atomic", remote, "main", "feature"); err == nil {
		t.Fatalf("atomic push succeeded, want rejection\n%s", out)
	}

	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != advanced {
		t.Errorf("main = %q, want %q (unchanged)", got, advanced)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/feature"); got != "" {
		t.Errorf("feature = %q, want absent — atomic rollback", got)
	}
}

// TestDefaultBranchForcePushGuard is the admin-only default-branch protection: a
// write collaborator may fast-forward the default branch and force-push a
// non-default branch, but a non-fast-forward of the default branch is refused;
// an admin may force-push it. (Golden + failure both, per CONTRIBUTING.)
func TestDefaultBranchForcePushGuard(t *testing.T) {
	ctx := context.Background()
	srv, m, _, _ := newWriteServer(t)
	r, _ := m.CreateRepo(ctx, repoName, "main")
	writeRaw := mintUser(t, m, r.ID, "collab", meta.PermWrite)
	adminRaw := mintUser(t, m, r.ID, "boss", meta.PermAdmin)
	writeRemote := authedURL(srv.URL, writeRaw) + "/" + repoName
	adminRemote := authedURL(srv.URL, adminRaw) + "/" + repoName

	// The collaborator establishes main (a create) and fast-forwards it — allowed.
	src := initCommit(t, "one\n")
	base := git(t, src, "rev-parse", "HEAD")
	git(t, src, "push", writeRemote, "main")
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "ff")
	git(t, src, "push", writeRemote, "main")
	ff := git(t, src, "rev-parse", "HEAD")

	// The collaborator rewrites history off base and force-pushes the DEFAULT
	// branch — refused; main stays at the fast-forward tip.
	git(t, src, "reset", "--hard", base)
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\nrewritten\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "rewrite")
	rewritten := git(t, src, "rev-parse", "HEAD")
	if out, err := tryGit(src, "push", "--force", writeRemote, "main"); err == nil {
		t.Fatalf("collaborator force-push of the default branch succeeded, want refusal\n%s", out)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != ff {
		t.Errorf("main after refused force = %q, want unchanged %q", got, ff)
	}

	// The same rewrite is fine on a NON-default branch: create feature at the
	// fast-forward tip, then force-push the rewrite over it — allowed.
	git(t, src, "push", writeRemote, ff+":refs/heads/feature")
	if out, err := tryGit(src, "push", "--force", writeRemote, "HEAD:refs/heads/feature"); err != nil {
		t.Fatalf("collaborator force-push of a non-default branch failed, want ok: %v\n%s", err, out)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/feature"); got != rewritten {
		t.Errorf("feature after force = %q, want %q", got, rewritten)
	}

	// An admin may force-push the default branch.
	if out, err := tryGit(src, "push", "--force", adminRemote, "main"); err != nil {
		t.Fatalf("admin force-push of the default branch failed, want ok: %v\n%s", err, out)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != rewritten {
		t.Errorf("main after admin force = %q, want %q", got, rewritten)
	}
}

// TestPublicRepoAnonymousReadWriteRefused proves visibility on the transport: a
// public repo clones with no credentials, but an anonymous push is refused.
func TestPublicRepoAnonymousReadWriteRefused(t *testing.T) {
	ctx := context.Background()
	srv, m, _, _ := newWriteServer(t)
	r, _ := m.CreateRepo(ctx, repoName, "main")
	if err := m.SetVisibility(ctx, r.ID, meta.VisibilityPublic); err != nil {
		t.Fatalf("SetVisibility: %v", err)
	}
	writeRaw := mintUser(t, m, r.ID, "collab", meta.PermWrite)

	// A collaborator seeds main.
	src := initCommit(t, "hello\n")
	git(t, src, "push", authedURL(srv.URL, writeRaw)+"/"+repoName, "main")
	head := git(t, src, "rev-parse", "HEAD")

	// Anonymous clone (no credentials) succeeds and matches.
	dst := filepath.Join(t.TempDir(), "clone")
	git(t, "", "clone", srv.URL+"/"+repoName, dst)
	if got := git(t, dst, "rev-parse", "HEAD"); got != head {
		t.Errorf("anon clone HEAD = %q, want %q", got, head)
	}

	// Anonymous push is refused, main unchanged.
	if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\nmore\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, src, "commit", "-am", "next")
	if out, err := tryGit(src, "push", srv.URL+"/"+repoName, "main"); err == nil {
		t.Fatalf("anonymous push to a public repo succeeded, want refusal\n%s", out)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
		t.Errorf("main after refused anon push = %q, want unchanged %q", got, head)
	}
}

// TestPrivateRepoSpectatorCannotPush proves the spectator (read ACL) can clone a
// private repo but not push it.
func TestPrivateRepoSpectatorCannotPush(t *testing.T) {
	ctx := context.Background()
	srv, m, _, _ := newWriteServer(t)
	r, _ := m.CreateRepo(ctx, repoName, "main") // private by default
	writeRaw := mintUser(t, m, r.ID, "collab", meta.PermWrite)
	readRaw := mintUser(t, m, r.ID, "spectator", meta.PermRead)

	src := initCommit(t, "hello\n")
	git(t, src, "push", authedURL(srv.URL, writeRaw)+"/"+repoName, "main")
	head := git(t, src, "rev-parse", "HEAD")

	// The spectator can clone (read ACL on a private repo).
	dst := filepath.Join(t.TempDir(), "clone")
	git(t, "", "clone", authedURL(srv.URL, readRaw)+"/"+repoName, dst)

	// But cannot push.
	if err := os.WriteFile(filepath.Join(dst, "file.txt"), []byte("hello\nedit\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, dst, "commit", "-am", "spectator edit")
	if out, err := tryGit(dst, "push", authedURL(srv.URL, readRaw)+"/"+repoName, "main"); err == nil {
		t.Fatalf("spectator push succeeded, want refusal\n%s", out)
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
		t.Errorf("main after refused spectator push = %q, want unchanged %q", got, head)
	}
}

func TestOrderingInvariantObjectsBeforeRef(t *testing.T) {
	ctx := context.Background()
	srv, m, s, writer := newWriteServer(t)
	r, _ := m.CreateRepo(ctx, repoName, "main")
	raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
	remote := authedURL(srv.URL, raw) + "/" + repoName

	// Inject a CAS failure that fires *after* the object PUT. This models a ref
	// CAS that loses the race (or any post-PUT failure): objects are already
	// durable, and the ref must never advance.
	writer.beforeCAS = func() error { return errors.New("injected cas failure") }

	src := initCommit(t, "hello\n")
	if out, err := tryGit(src, "push", remote, "main"); err == nil {
		t.Fatalf("push succeeded despite injected CAS failure\n%s", out)
	}

	// Ordering invariant: the objects reached the store (harmless orphans that
	// gc reclaims), but the ref never advanced (no corruption).
	objs, _ := s.List(ctx, repoName+"/objects/")
	if len(objs) == 0 {
		t.Error("expected orphan objects in the store (content-before-pointer)")
	}
	if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != "" {
		t.Errorf("main = %q, want absent — ref must not advance when CAS fails", got)
	}
}