cmd/gitbayd/hook_test.go
188 lines · 5981 bytes
6 symbols in this file
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}