cmd/gitbayd/hook_test.go

dd06e80a071d451ebb7b083363e0580550481114
gitbay/cmd/gitbayd/hook_test.go history · blame · raw

188 lines · 5981 bytes

  1package main
  2
  3import (
  4	"fmt"
  5	"os"
  6	"os/exec"
  7	"strings"
  8	"testing"
  9
 10	"gitbay.org/gitbay/internal/hookd"
 11	"gitbay.org/gitbay/internal/policy"
 12)
 13
 14// fastImportRepo builds a repository with n commits on main in one
 15// process. A loop of `git commit` would be n forks, which is the cost this
 16// test exists to rule out of the code under test.
 17func fastImportRepo(t *testing.T, n int) (string, string) {
 18	t.Helper()
 19	dir := t.TempDir()
 20	run := func(args ...string) {
 21		t.Helper()
 22		cmd := exec.Command("git", args...)
 23		cmd.Dir = dir
 24		if out, err := cmd.CombinedOutput(); err != nil {
 25			t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
 26		}
 27	}
 28	run("init", "-q", "--initial-branch=main", ".")
 29
 30	var b strings.Builder
 31	for i := 1; i <= n; i++ {
 32		fmt.Fprintf(&b, "commit refs/heads/main\n")
 33		fmt.Fprintf(&b, "mark :%d\n", i)
 34		fmt.Fprintf(&b, "author A U Thor <a@example.test> %d +0000\n", 1600000000+i)
 35		fmt.Fprintf(&b, "committer A U Thor <a@example.test> %d +0000\n", 1600000000+i)
 36		msg := fmt.Sprintf("commit %d", i)
 37		fmt.Fprintf(&b, "data %d\n%s\n", len(msg), msg)
 38		if i > 1 {
 39			fmt.Fprintf(&b, "from :%d\n", i-1)
 40		}
 41		fmt.Fprintf(&b, "M 644 inline f.txt\ndata %d\n%d\n", len(fmt.Sprint(i))+1, i)
 42		fmt.Fprintf(&b, "\n")
 43	}
 44	cmd := exec.Command("git", "fast-import", "--quiet")
 45	cmd.Dir = dir
 46	cmd.Stdin = strings.NewReader(b.String())
 47	if out, err := cmd.CombinedOutput(); err != nil {
 48		t.Fatalf("fast-import: %v\n%s", err, out)
 49	}
 50	// pre-receive runs before the ref moves, so the incoming objects are
 51	// present but reachable from nothing — which is what makes
 52	// `rev-list --not --all` list them. Dropping the ref reproduces that;
 53	// the objects stay until a gc that never runs here.
 54	head := gitOut(t, dir, "rev-parse", "main")
 55	run("update-ref", "-d", "refs/heads/main")
 56	return dir, head
 57}
 58
 59// streamIncomingCommits runs in the hook process, whose working directory
 60// is the repository, so the test chdirs the same way git would.
 61func inRepo(t *testing.T, dir string, f func()) {
 62	t.Helper()
 63	prev, err := os.Getwd()
 64	if err != nil {
 65		t.Fatal(err)
 66	}
 67	if err := os.Chdir(dir); err != nil {
 68		t.Fatal(err)
 69	}
 70	defer os.Chdir(prev)
 71	f()
 72}
 73
 74// A push large enough that its object names do not fit a pipe buffer:
 75// writing them all before reading git's output deadlocks, which is why
 76// stdin is fed from its own goroutine. 4000 names is roughly 160 KiB,
 77// well past the usual 64 KiB pipe.
 78func TestStreamIncomingCommitsLargePush(t *testing.T) {
 79	const n = 4000
 80	dir, head := fastImportRepo(t, n)
 81
 82	// No ref exists, so every commit is incoming — the shape of a
 83	// first push of an existing history.
 84	updates := []policy.RefUpdate{{Old: strings.Repeat("0", 40), New: head, Ref: "refs/heads/main"}}
 85
 86	var got []hookd.RawCommit
 87	inRepo(t, dir, func() {
 88		if err := streamIncomingCommits(updates, func(rc hookd.RawCommit) error {
 89			got = append(got, rc)
 90			return nil
 91		}); err != nil {
 92			t.Fatalf("streaming: %v", err)
 93		}
 94	})
 95	if len(got) != n {
 96		t.Fatalf("streamed %d commits, want %d", len(got), n)
 97	}
 98	seen := map[string]bool{}
 99	for _, rc := range got {
100		if seen[rc.SHA] {
101			t.Fatalf("%s streamed twice", rc.SHA)
102		}
103		seen[rc.SHA] = true
104		// The raw object is what signature verification parses; a record
105		// misread by a byte would still look plausible here without this.
106		if !strings.HasPrefix(string(rc.Raw), "tree ") {
107			t.Fatalf("%s does not look like a raw commit: %.60q", rc.SHA, rc.Raw)
108		}
109		if !strings.Contains(string(rc.Raw), "A U Thor <a@example.test>") {
110			t.Fatalf("%s raw object is truncated: %.200q", rc.SHA, rc.Raw)
111		}
112	}
113	if !seen[head] {
114		t.Fatal("the pushed tip was not among the streamed commits")
115	}
116}
117
118// A ref that already exists contributes nothing, and a delete contributes
119// nothing: neither introduces an object to verify.
120func TestStreamIncomingCommitsNothingToDo(t *testing.T) {
121	dir, head := fastImportRepo(t, 3)
122	zero := strings.Repeat("0", 40)
123
124	inRepo(t, dir, func() {
125		n := 0
126		err := streamIncomingCommits([]policy.RefUpdate{
127			{Old: head, New: zero, Ref: "refs/heads/main", IsDelete: true},
128		}, func(hookd.RawCommit) error { n++; return nil })
129		if err != nil || n != 0 {
130			t.Fatalf("delete streamed %d commits (%v)", n, err)
131		}
132	})
133
134	// With the ref restored the tip is reachable, so nothing is incoming:
135	// a push of what the repository already has verifies nothing.
136	cmd := exec.Command("git", "update-ref", "refs/heads/main", head)
137	cmd.Dir = dir
138	if out, err := cmd.CombinedOutput(); err != nil {
139		t.Fatalf("update-ref: %v\n%s", err, out)
140	}
141	inRepo(t, dir, func() {
142		n := 0
143		err := streamIncomingCommits([]policy.RefUpdate{
144			{Old: head, New: head, Ref: "refs/heads/main"},
145		}, func(hookd.RawCommit) error { n++; return nil })
146		if err != nil || n != 0 {
147			t.Fatalf("already-present tip streamed %d commits (%v)", n, err)
148		}
149	})
150}
151
152// An error from the callback — the socket going away mid-push — stops the
153// walk instead of reading the rest of the history into nothing.
154func TestStreamIncomingCommitsCallbackError(t *testing.T) {
155	// Large enough that git is still writing when the callback gives up:
156	// without killing it, git blocks on a full pipe and Wait blocks on
157	// git, and this test hangs rather than fails.
158	dir, head := fastImportRepo(t, 4000)
159	inRepo(t, dir, func() {
160		n := 0
161		err := streamIncomingCommits(
162			[]policy.RefUpdate{{Old: strings.Repeat("0", 40), New: head, Ref: "refs/heads/main"}},
163			func(hookd.RawCommit) error {
164				n++
165				if n == 5 {
166					return fmt.Errorf("socket closed")
167				}
168				return nil
169			})
170		if err == nil {
171			t.Fatal("callback error did not stop the walk")
172		}
173		if n != 5 {
174			t.Fatalf("kept streaming after the error: %d commits", n)
175		}
176	})
177}
178
179func gitOut(t *testing.T, dir string, args ...string) string {
180	t.Helper()
181	cmd := exec.Command("git", args...)
182	cmd.Dir = dir
183	out, err := cmd.Output()
184	if err != nil {
185		t.Fatalf("git %s: %v", strings.Join(args, " "), err)
186	}
187	return strings.TrimSpace(string(out))
188}