e2e/mr_test.go
291 lines · 11823 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "golang.org/x/crypto/ssh"
11
12 "gitbay.org/gitbay/internal/sig"
13)
14
15type mrShow struct {
16 Number int64 `json:"number"`
17 State string `json:"state"`
18 Source string `json:"source"`
19 TargetRef string `json:"target_ref"`
20 HeadSHA string `json:"head_sha"`
21 MergedAt string `json:"merged_at"`
22 MergedBy string `json:"merged_by"`
23 ClosedAt string `json:"closed_at"`
24 ClosedBy string `json:"closed_by"`
25 Reviews []struct {
26 Reviewer string `json:"reviewer"`
27 Verdict string `json:"verdict"`
28 Stale bool `json:"stale"`
29 CreatedAt string `json:"created_at"`
30 } `json:"reviews"`
31}
32
33func (i *instance) mrShow(t *testing.T, key, repo, n string) mrShow {
34 t.Helper()
35 out, errOut, code := i.ssh(t, key, "", "mr", "show", repo, n, "--json")
36 if code != 0 {
37 t.Fatalf("mr show: exit %d, %s", code, errOut)
38 }
39 var env struct {
40 Data mrShow `json:"data"`
41 }
42 if err := json.Unmarshal([]byte(out), &env); err != nil {
43 t.Fatalf("mr show JSON: %v\n%s", err, out)
44 }
45 return env.Data
46}
47
48func TestMergeRequests(t *testing.T) {
49 inst := startInstance(t)
50
51 aliceKey := inst.newKey(t, "alice")
52 bobKey := inst.newKey(t, "bob")
53 inst.admin(t, "admin", "user", "create", "alice",
54 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
55 inst.admin(t, "admin", "user", "create", "bob",
56 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
57
58 // Alice's upstream repo with an initial commit.
59 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
60 t.Fatalf("repo create: %s", errOut)
61 }
62 aliceEnv := inst.gitEnv(aliceKey)
63 aliceWork := t.TempDir()
64 mustGit(t, aliceWork, aliceEnv, "clone", inst.sshURL("alice/lib"), "w")
65 aliceDir := filepath.Join(aliceWork, "w")
66 os.WriteFile(filepath.Join(aliceDir, "lib.txt"), []byte("v1\n"), 0o644)
67 mustGit(t, aliceDir, aliceEnv, "checkout", "-q", "-b", "main")
68 mustGit(t, aliceDir, aliceEnv, "add", ".")
69 mustGit(t, aliceDir, aliceEnv, "commit", "-q", "-m", "base")
70 mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main")
71
72 // Bob forks and pushes a feature branch to his fork.
73 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/lib"); code != 0 {
74 t.Fatalf("fork: %s", errOut)
75 }
76 bobEnv := inst.gitEnv(bobKey)
77 bobWork := t.TempDir()
78 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("bob/lib"), "w")
79 bobDir := filepath.Join(bobWork, "w")
80 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main")
81 os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644)
82 mustGit(t, bobDir, bobEnv, "add", ".")
83 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature")
84 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature")
85
86 // MR from the fork into alice/lib.
87 out, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
88 "--source", "bob/lib:feature", "--target", "main", "--title", "'add feature'", "--json")
89 if code != 0 {
90 t.Fatalf("mr create: %s", errOut)
91 }
92 if !strings.Contains(out, `"number":1`) {
93 t.Fatalf("mr create output: %s", out)
94 }
95
96 // The MR head ref is fetchable from the TARGET repo by a reader.
97 fetchDir := t.TempDir()
98 mustGit(t, fetchDir, aliceEnv, "clone", "-q", inst.sshURL("alice/lib"), "c")
99 mustGit(t, filepath.Join(fetchDir, "c"), aliceEnv, "fetch", "-q", "origin", "refs/merge-requests/1/head")
100
101 // Alice approves.
102 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "review", "alice/lib", "1", "--approve"); code != 0 {
103 t.Fatalf("review: %s", errOut)
104 }
105 show := inst.mrShow(t, aliceKey, "alice/lib", "1")
106 if len(show.Reviews) != 1 || show.Reviews[0].Stale {
107 t.Fatalf("fresh review wrong: %+v", show.Reviews)
108 }
109 firstHead := show.HeadSHA
110
111 // Bob force-pushes a changed diff: the MR head updates and the review
112 // goes stale. (A force-push carrying the same diff keeps it, #198.)
113 os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work, amended\n"), 0o644)
114 mustGit(t, bobDir, bobEnv, "commit", "-q", "-a", "--amend", "-m", "add feature (amended)")
115 mustGit(t, bobDir, bobEnv, "push", "-q", "--force", "origin", "feature")
116 show = inst.mrShow(t, aliceKey, "alice/lib", "1")
117 if show.HeadSHA == firstHead {
118 t.Fatal("MR head not updated after force-push")
119 }
120 if len(show.Reviews) != 1 || !show.Reviews[0].Stale {
121 t.Fatalf("review not marked stale: %+v", show.Reviews)
122 }
123
124 // Target advances, so fast-forward is impossible: default merge makes a
125 // merge commit authored by the merging user.
126 mustGit(t, aliceDir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline moves on")
127 mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main")
128 out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--json")
129 if code != 0 {
130 t.Fatalf("merge: exit %d, %s", code, errOut)
131 }
132 if !strings.Contains(out, `"strategy":"merge"`) {
133 t.Fatalf("expected merge-commit strategy: %s", out)
134 }
135 if inst.mrShow(t, aliceKey, "alice/lib", "1").State != "merged" {
136 t.Fatal("MR not marked merged")
137 }
138 mustGit(t, aliceDir, aliceEnv, "pull", "-q", "origin", "main")
139 if _, err := os.Stat(filepath.Join(aliceDir, "feature.txt")); err != nil {
140 t.Fatal("merged content missing from main")
141 }
142 // The merge commit carries the merging user's identity and is unsigned.
143 tip := strings.TrimSpace(mustGit(t, aliceDir, aliceEnv, "log", "-1", "--format=%an <%ae>"))
144 if tip != "alice <alice@example.test>" {
145 t.Fatalf("merge commit identity: %q", tip)
146 }
147 logOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/lib", "--limit", "1")
148 if !strings.Contains(logOut, "unsigned") {
149 t.Fatalf("merge commit should display unsigned:\n%s", logOut)
150 }
151
152 // --- require_signed_commits: push-time and merge-time policy ---
153
154 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/sec"); code != 0 {
155 t.Fatalf("create sec: %s", errOut)
156 }
157 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/sec", "on"); code != 0 {
158 t.Fatalf("require-signed: %s", errOut)
159 }
160 secWork := t.TempDir()
161 mustGit(t, secWork, aliceEnv, "clone", inst.sshURL("alice/sec"), "w")
162 secDir := filepath.Join(secWork, "w")
163
164 // Unsigned push is rejected at pre-receive.
165 os.WriteFile(filepath.Join(secDir, "a.txt"), []byte("a\n"), 0o644)
166 mustGit(t, secDir, aliceEnv, "checkout", "-q", "-b", "main")
167 mustGit(t, secDir, aliceEnv, "add", ".")
168 mustGit(t, secDir, aliceEnv, "commit", "-q", "-m", "unsigned attempt")
169 pushOut, pushCode := gitRun(t, secDir, aliceEnv, "push", "origin", "main")
170 if pushCode == 0 {
171 t.Fatal("unsigned push accepted into require-signed repo")
172 }
173 if !strings.Contains(pushOut, "requires signed commits") {
174 t.Fatalf("unsigned push message:\n%s", pushOut)
175 }
176
177 // SSHSIG-signed commits go through.
178 raw, _ := os.ReadFile(aliceKey)
179 signer, err := ssh.ParsePrivateKey(raw)
180 if err != nil {
181 t.Fatal(err)
182 }
183 signAlice := func(p []byte) string {
184 s, err := sig.MarshalSSHSig(signer, p)
185 if err != nil {
186 t.Fatal(err)
187 }
188 return string(s)
189 }
190 buildCommits(t, secDir, aliceEnv, []commitSpec{
191 {authorEmail: "alice@example.test", subject: "signed base", sign: signAlice},
192 })
193 mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "main")
194
195 // A signed feature branch and a same-repo MR.
196 base := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main"))
197 tree := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main^{tree}"))
198 buildChain(t, secDir, aliceEnv, tree, base, []commitSpec{
199 {authorEmail: "alice@example.test", subject: "signed feature", sign: signAlice},
200 })
201 // buildChain moved refs/heads/main; restore and use a feature branch.
202 feat := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main"))
203 mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/main", base)
204 mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/feat", feat)
205 mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "feat")
206
207 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "create", "alice/sec",
208 "--source", "feat", "--target", "main", "--title", "'signed work'"); code != 0 {
209 t.Fatalf("sec mr create: %s", errOut)
210 }
211
212 // An explicit merge-commit strategy is refused with exit 4 and rebase
213 // instructions.
214 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--strategy", "merge")
215 if code != 4 {
216 t.Fatalf("merge-commit on require-signed: exit %d (want 4), %s", code, errOut)
217 }
218 if !strings.Contains(errOut, "only fast-forward") || !strings.Contains(errOut, "rebase") {
219 t.Fatalf("refusal message: %s", errOut)
220 }
221
222 // Fast-forward merge of verified commits succeeds.
223 out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--json")
224 if code != 0 {
225 t.Fatalf("ff merge: %s", errOut)
226 }
227 if !strings.Contains(out, `"strategy":"ff"`) {
228 t.Fatalf("expected ff: %s", out)
229 }
230
231 // --- fork deletion leaves the MR diff intact ---
232
233 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "second", "origin/main")
234 os.WriteFile(filepath.Join(bobDir, "second.txt"), []byte("more\n"), 0o644)
235 mustGit(t, bobDir, bobEnv, "add", ".")
236 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "second feature")
237 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "second")
238 if _, errOut, code = inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
239 "--source", "bob/lib:second", "--target", "main", "--title", "'second'"); code != 0 {
240 t.Fatalf("mr 2 create: %s", errOut)
241 }
242 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "delete", "bob/lib", "--yes"); code != 0 {
243 t.Fatalf("fork delete: %s", errOut)
244 }
245 show = inst.mrShow(t, aliceKey, "alice/lib", "2")
246 if show.State != "source_gone" {
247 t.Fatalf("MR 2 state after fork deletion: %s", show.State)
248 }
249 diffOut, errOut, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "2")
250 if code != 0 || !strings.Contains(diffOut, "second.txt") {
251 t.Fatalf("diff after fork deletion: exit %d\n%s%s", code, diffOut, errOut)
252 }
253 // And it can still be merged: the target owns the objects.
254 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "2"); code != 0 {
255 t.Fatalf("merge after fork deletion: %s", errOut)
256 }
257
258 // Merged MRs keep their historical diff: after the fast-forward the
259 // live merge-base equals the head, so the recorded base must be used.
260 diffOut2, _, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "1")
261 if code != 0 || !strings.Contains(diffOut2, "feature.txt") {
262 t.Fatalf("post-merge diff empty: %d\n%s", code, diffOut2)
263 }
264
265 // Web read views.
266 status, body := inst.get(t, "/alice/lib/mrs?state=all")
267 if status != 200 || !strings.Contains(body, "add feature") || !strings.Contains(body, "second") {
268 t.Fatalf("mrs page: %d\n%s", status, body)
269 }
270 status, body = inst.get(t, "/alice/lib/mrs/1")
271 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
272 t.Fatalf("mr detail: %d\n%s", status, body)
273 }
274 if _, diff := inst.get(t, "/alice/lib/mrs/1?view=diff"); !strings.Contains(diff, "feature.txt") {
275 t.Fatalf("merged MR web diff empty:\n%s", diff)
276 }
277 // The MR lists the commits it carries, linked to commit pages.
278 _, commits := inst.get(t, "/alice/lib/mrs/1?view=commits")
279 if !strings.Contains(commits, "/alice/lib/commit/") {
280 t.Fatalf("mr commits view missing:\n%s", commits)
281 }
282 // So does mr show, human and JSON.
283 showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1")
284 if code != 0 || !strings.Contains(showOut, "commit: ") {
285 t.Fatalf("mr show missing commits: %d\n%s", code, showOut)
286 }
287 showJSON, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1", "--json")
288 if !strings.Contains(showJSON, `"commits":[`) || !strings.Contains(showJSON, `"subject"`) {
289 t.Fatalf("mr show json missing commits: %s", showJSON)
290 }
291}