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