e2e/mr_test.go

dd06e80a071d451ebb7b083363e0580550481114
gitbay/e2e/mr_test.go history · blame · raw

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}