e2e/mrweb_test.go

v0.5.0
gitbay/e2e/mrweb_test.go history · blame · raw

216 lines · 8842 bytes

  1package e2e
  2
  3import (
  4	"encoding/json"
  5	"net/http"
  6	"net/url"
  7	"os"
  8	"path/filepath"
  9	"regexp"
 10	"strings"
 11	"testing"
 12)
 13
 14// login returns a browser holding a session for the given key's account.
 15func (i *instance) login(t *testing.T, key string) *http.Client {
 16	t.Helper()
 17	out, errOut, code := i.ssh(t, key, "", "web", "login", "--json")
 18	if code != 0 {
 19		t.Fatalf("web login: %s", errOut)
 20	}
 21	var env struct {
 22		Data struct {
 23			URL string `json:"url"`
 24		} `json:"data"`
 25	}
 26	json.Unmarshal([]byte(out), &env)
 27	c := newBrowser(t)
 28	path := env.Data.URL[strings.Index(env.Data.URL, "/login"):]
 29	if status, _ := browserGet(t, c, i.base()+path); status != 200 {
 30		t.Fatalf("login landed: %d", status)
 31	}
 32	return c
 33}
 34
 35// TestMRWebReviewLoop drives review, thread resolution, and merge from the
 36// browser. Every action runs the same control command the CLI runs, so the
 37// test also proves the merge gates apply to web merges.
 38func TestMRWebReviewLoop(t *testing.T) {
 39	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
 40	aliceKey := inst.newKey(t, "alice")
 41	bobKey := inst.newKey(t, "bob")
 42	inst.admin(t, "admin", "user", "create", "alice",
 43		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 44	inst.admin(t, "admin", "user", "create", "bob",
 45		"--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
 46
 47	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
 48		t.Fatalf("repo create: %s", errOut)
 49	}
 50	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/lib", "bob", "write"); code != 0 {
 51		t.Fatalf("grant: %s", errOut)
 52	}
 53	// Unresolved review threads block merges, so the gate is observable.
 54	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-resolved", "alice/lib", "on"); code != 0 {
 55		t.Fatalf("require-resolved: %s", errOut)
 56	}
 57
 58	env := inst.gitEnv(aliceKey)
 59	work := t.TempDir()
 60	mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
 61	dir := filepath.Join(work, "w")
 62	os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v1\n"), 0o644)
 63	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 64	mustGit(t, dir, env, "add", ".")
 65	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 66	mustGit(t, dir, env, "push", "-q", "origin", "main")
 67
 68	// Bob proposes a change and leaves a review thread on it.
 69	bobEnv := inst.gitEnv(bobKey)
 70	bobWork := t.TempDir()
 71	mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/lib"), "w")
 72	bobDir := filepath.Join(bobWork, "w")
 73	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main")
 74	os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644)
 75	mustGit(t, bobDir, bobEnv, "add", ".")
 76	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature")
 77	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature")
 78	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
 79		"--source", "feature", "--target", "main", "--title", "'add feature'"); code != 0 {
 80		t.Fatalf("mr create: %s", errOut)
 81	}
 82	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "diff-comment", "alice/lib", "1",
 83		"--path", "feature.txt", "--line", "1", "--message", "'is this right?'"); code != 0 {
 84		t.Fatalf("diff-comment: %s", errOut)
 85	}
 86
 87	mrURL := inst.base() + "/alice/lib/mrs/1"
 88	alice := inst.login(t, aliceKey)
 89
 90	// The controls are on the page, and carry the thread to resolve.
 91	_, body := browserGet(t, alice, mrURL)
 92	for _, want := range []string{`value="approve"`, `action="/alice/lib/mrs/1/merge"`} {
 93		if !strings.Contains(body, want) {
 94			t.Fatalf("MR page missing %q", want)
 95		}
 96	}
 97	// Review threads live on the diff view, where their lines are.
 98	_, diffBody := browserGet(t, alice, mrURL+"?view=diff")
 99	m := regexp.MustCompile(`name="thread" value="(\d+)"`).FindStringSubmatch(diffBody)
100	if m == nil {
101		t.Fatalf("no thread control on the diff view:\n%s", diffBody)
102	}
103	threadID := m[1]
104
105	// Approve from the browser; the CLI sees the review.
106	if status, _ := browserPost(t, alice, mrURL+"/review", url.Values{"verdict": {"approve"}}); status != 200 {
107		t.Fatalf("review post: %d", status)
108	}
109	show := inst.mrShow(t, aliceKey, "alice/lib", "1")
110	if len(show.Reviews) != 1 || show.Reviews[0].Reviewer != "alice" || show.Reviews[0].Verdict != "approve" {
111		t.Fatalf("review not recorded: %+v", show.Reviews)
112	}
113
114	// Merging is refused while the thread is open, and the page says why.
115	_, body = browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}})
116	if !strings.Contains(body, "unresolved") {
117		t.Fatalf("merge gate not surfaced:\n%s", body)
118	}
119	if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "open" {
120		t.Fatalf("blocked merge changed state to %s", st)
121	}
122
123	// Resolve the thread, then merge.
124	if status, _ := browserPost(t, alice, mrURL+"/thread",
125		url.Values{"thread": {threadID}, "action": {"resolve"}}); status != 200 {
126		t.Fatalf("resolve post: %d", status)
127	}
128	out, _, _ := inst.ssh(t, aliceKey, "", "mr", "threads", "alice/lib", "1")
129	if !strings.Contains(out, "resolved") {
130		t.Fatalf("thread not resolved:\n%s", out)
131	}
132	if status, _ := browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}}); status != 200 {
133		t.Fatalf("merge post: %d", status)
134	}
135	if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "merged" {
136		t.Fatalf("MR state after web merge: %s", st)
137	}
138	mustGit(t, dir, env, "pull", "-q", "origin", "main")
139	if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil {
140		t.Fatal("merged content missing from main")
141	}
142
143	// Readers get no controls, and a forged POST is refused by the command.
144	_, anon := browserGet(t, newBrowser(t), mrURL)
145	if strings.Contains(anon, `value="approve"`) {
146		t.Fatal("anonymous visitor sees review controls")
147	}
148	carol := inst.newKey(t, "carol")
149	inst.admin(t, "admin", "user", "create", "carol", "--key", carol+".pub")
150	if _, errOut, code := inst.ssh(t, carol, "", "repo", "create", "carol/own"); code != 0 {
151		t.Fatalf("carol repo: %s", errOut)
152	}
153	_, denied := browserPost(t, inst.login(t, carol), mrURL+"/close", url.Values{})
154	if !strings.Contains(denied, `class="error"`) || !strings.Contains(denied, "write access") {
155		t.Fatalf("reader was not refused:\n%s", denied)
156	}
157}
158
159// TestMRWebCreate opens a merge request from the browser and checks the
160// form survives a refusal with the draft intact.
161func TestMRWebCreate(t *testing.T) {
162	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
163	aliceKey := inst.newKey(t, "alice")
164	inst.admin(t, "admin", "user", "create", "alice",
165		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
166	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
167		t.Fatalf("repo create: %s", errOut)
168	}
169	env := inst.gitEnv(aliceKey)
170	work := t.TempDir()
171	mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
172	dir := filepath.Join(work, "w")
173	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
174	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
175	mustGit(t, dir, env, "add", ".")
176	mustGit(t, dir, env, "commit", "-q", "-m", "base")
177	mustGit(t, dir, env, "push", "-q", "origin", "main")
178	mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
179	os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
180	mustGit(t, dir, env, "add", ".")
181	mustGit(t, dir, env, "commit", "-q", "-m", "topic work")
182	mustGit(t, dir, env, "push", "-q", "origin", "topic")
183
184	alice := inst.login(t, aliceKey)
185	base := inst.base() + "/alice/lib"
186
187	// The list links to the form, and the form offers the pushed branches.
188	if _, body := browserGet(t, alice, base+"/mrs"); !strings.Contains(body, "/alice/lib/mrs/new") {
189		t.Fatalf("no create link on the list:\n%s", body)
190	}
191	_, form := browserGet(t, alice, base+"/mrs/new")
192	for _, want := range []string{`name="source"`, `value="topic"`, `value="main"`} {
193		if !strings.Contains(form, want) {
194			t.Fatalf("form missing %q:\n%s", want, form)
195		}
196	}
197
198	// A refusal keeps the draft: the branch does not exist.
199	_, retry := browserPost(t, alice, base+"/mrs/new", url.Values{
200		"source": {"nope"}, "target": {"main"}, "title": {"my title"}, "body": {"my body"}})
201	if !strings.Contains(retry, `class="error"`) || !strings.Contains(retry, "my title") ||
202		!strings.Contains(retry, "my body") {
203		t.Fatalf("refusal lost the draft:\n%s", retry)
204	}
205
206	// A real one lands on the merge request it created.
207	status, created := browserPost(t, alice, base+"/mrs/new", url.Values{
208		"source": {"topic"}, "target": {"main"}, "title": {"topic into main"}, "body": {"please review"}})
209	if status != 200 || !strings.Contains(created, "topic into main") {
210		t.Fatalf("create failed: %d\n%s", status, created)
211	}
212	show := inst.mrShow(t, aliceKey, "alice/lib", "1")
213	if show.State != "open" || show.Source != "topic" {
214		t.Fatalf("created MR wrong: %+v", show)
215	}
216}