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