internal/httpd/mrrangediff_test.go

8dcfa45a8ac03a5ff9c36828274d05acadcf846c
gitbay/internal/httpd/mrrangediff_test.go history · blame · raw

353 lines · 11553 bytes

  1package httpd
  2
  3import (
  4	"net/http"
  5	"net/http/httptest"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"strconv"
 10	"strings"
 11	"testing"
 12	"time"
 13
 14	"gitbay.org/gitbay/internal/config"
 15	"gitbay.org/gitbay/internal/control"
 16	"gitbay.org/gitbay/internal/store"
 17	"gitbay.org/gitbay/internal/web"
 18)
 19
 20// The range-diff page dispatches mr range-diff and renders its text
 21// output, the same comparison the CLI and iOS already show (#269).
 22func TestMRRangeDiffPageRendersCommandOutput(t *testing.T) {
 23	st, err := store.Open(":memory:")
 24	if err != nil {
 25		t.Fatal(err)
 26	}
 27	defer st.Close()
 28	if err := st.MigrateUp(); err != nil {
 29		t.Fatal(err)
 30	}
 31	uid, err := st.CreateUser("alice", false)
 32	if err != nil {
 33		t.Fatal(err)
 34	}
 35	if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
 36		t.Fatal(err)
 37	}
 38
 39	s := New(config.Default(), st, nil)
 40	req := httptest.NewRequest("GET", "/alice/app/mrs/1/range-diff", nil)
 41	req.SetPathValue("owner", "alice")
 42	req.SetPathValue("repo", "app")
 43	req.SetPathValue("n", "1")
 44	rr := httptest.NewRecorder()
 45	s.mrRangeDiff(rr, req)
 46
 47	// No merge request 1 exists yet, so this must 404 rather than error.
 48	if rr.Code != 404 {
 49		t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
 50	}
 51}
 52
 53// rangeDiffGitEnv and rangeDiffGitRunner build real git history on disk,
 54// the way internal/control's own build tests do, since mr range-diff
 55// runs actual git commands against the repository's bare directory — a
 56// store-only fixture cannot exercise it.
 57func rangeDiffGitEnv() []string {
 58	return append(os.Environ(),
 59		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
 60		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
 61		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
 62}
 63
 64func rangeDiffGitRunner(t *testing.T) func(dir string, args ...string) string {
 65	t.Helper()
 66	env := rangeDiffGitEnv()
 67	return func(dir string, args ...string) string {
 68		t.Helper()
 69		cmd := exec.Command("git", args...)
 70		cmd.Dir = dir
 71		cmd.Env = env
 72		out, err := cmd.CombinedOutput()
 73		if err != nil {
 74			t.Fatalf("git %v: %v\n%s", args, err, out)
 75		}
 76		return string(out)
 77	}
 78}
 79
 80// rangeDiffFixture builds a private repository owned by alice with a
 81// merge request that has two real revisions on disk — an "add b" commit,
 82// then the same commit amended with one changed line — the same shape
 83// e2e/rangediff_test.go:14-38 builds for the CLI. bob holds no access to
 84// the repository, so he stands in for a non-reader.
 85func rangeDiffFixture(t *testing.T) (st *store.Store, cfg config.Config, alice, bob store.User, repo store.Repo, n int64, v1, v2, title string) {
 86	t.Helper()
 87	root := t.TempDir()
 88	st, err := store.Open(filepath.Join(root, "gitbay.db"))
 89	if err != nil {
 90		t.Fatal(err)
 91	}
 92	t.Cleanup(func() { st.Close() })
 93	if err := st.MigrateUp(); err != nil {
 94		t.Fatal(err)
 95	}
 96	aliceID, err := st.CreateUser("alice", false)
 97	if err != nil {
 98		t.Fatal(err)
 99	}
100	bobID, err := st.CreateUser("bob", false)
101	if err != nil {
102		t.Fatal(err)
103	}
104	repoID, err := st.CreateRepo("user", aliceID, "secret", "private")
105	if err != nil {
106		t.Fatal(err)
107	}
108	repo, err = st.RepoByID(repoID)
109	if err != nil {
110		t.Fatal(err)
111	}
112
113	git := rangeDiffGitRunner(t)
114	dir := control.RepoDir(root, repo.OwnerName, repo.Name)
115	if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
116		t.Fatal(err)
117	}
118	git(root, "init", "-q", "--bare", dir)
119
120	src := filepath.Join(root, "src")
121	git(root, "init", "-q", "-b", "main", "src")
122	os.WriteFile(filepath.Join(src, "a.txt"), []byte("one\n"), 0o644)
123	git(src, "add", ".")
124	git(src, "commit", "-q", "-m", "base")
125	git(src, "push", "-q", dir, "main")
126
127	git(src, "checkout", "-q", "-b", "feat")
128	os.WriteFile(filepath.Join(src, "b.txt"), []byte("alpha\nbeta\ngamma\n"), 0o644)
129	git(src, "add", ".")
130	git(src, "commit", "-q", "-m", "add b")
131	git(src, "push", "-q", dir, "feat")
132	v1 = strings.TrimSpace(git(src, "rev-parse", "HEAD"))
133
134	os.WriteFile(filepath.Join(src, "b.txt"), []byte("alpha\nbeta revised\ngamma\n"), 0o644)
135	git(src, "add", ".")
136	git(src, "commit", "-q", "--amend", "--no-edit")
137	git(src, "push", "-q", "--force", dir, "feat")
138	v2 = strings.TrimSpace(git(src, "rev-parse", "HEAD"))
139
140	title = "range diff of a secret plan"
141	n, err = st.CreateMR(repo.ID, aliceID, repo.ID, "feat", "main", title, "", v1, "md", false)
142	if err != nil {
143		t.Fatal(err)
144	}
145	mr, err := st.MRByNumber(repo.ID, n)
146	if err != nil {
147		t.Fatal(err)
148	}
149	if err := st.UpdateMRHead(mr.ID, v2, "", false); err != nil {
150		t.Fatal(err)
151	}
152
153	cfg = config.Default()
154	cfg.Server.Root = root
155	cfg.Web.Mode = "accounts"
156
157	alice = store.User{ID: aliceID, Username: "alice"}
158	bob = store.User{ID: bobID, Username: "bob"}
159	return
160}
161
162// loginCookie mints a real web session, the same as a browser login
163// would, so a handler under test reads a viewer through s.viewer /
164// s.webViewer exactly as it does in production.
165func loginCookie(t *testing.T, st *store.Store, userID int64) *http.Cookie {
166	t.Helper()
167	tok, hash, err := store.NewToken()
168	if err != nil {
169		t.Fatal(err)
170	}
171	if err := st.CreateWebSession(hash, userID, time.Hour); err != nil {
172		t.Fatal(err)
173	}
174	return &http.Cookie{Name: sessionCookie, Value: tok}
175}
176
177// The range-diff page must answer exactly as the MR page does: the owner
178// reads it, and a private repository is 404 — never 403, which would
179// confirm the namespace — for both an anonymous caller and a signed-in
180// user with no access (#269).
181func TestMRRangeDiffPagePrivateRepo(t *testing.T) {
182	st, cfg, alice, bob, repo, n, _, _, title := rangeDiffFixture(t)
183	s := New(cfg, st, nil)
184
185	newReq := func(cookie *http.Cookie) (*httptest.ResponseRecorder, *http.Request) {
186		req := httptest.NewRequest("GET", "/alice/secret/mrs/"+strconv.FormatInt(n, 10)+"/range-diff", nil)
187		req.SetPathValue("owner", repo.OwnerName)
188		req.SetPathValue("repo", repo.Name)
189		req.SetPathValue("n", strconv.FormatInt(n, 10))
190		if cookie != nil {
191			req.AddCookie(cookie)
192		}
193		return httptest.NewRecorder(), req
194	}
195
196	t.Run("owner reads it", func(t *testing.T) {
197		rr, req := newReq(loginCookie(t, st, alice.ID))
198		s.mrRangeDiff(rr, req)
199		if rr.Code != 200 {
200			t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
201		}
202		if !strings.Contains(rr.Body.String(), title) {
203			t.Errorf("owner's page does not show the MR title %q:\n%s", title, rr.Body.String())
204		}
205	})
206
207	t.Run("anonymous gets 404 and no title", func(t *testing.T) {
208		rr, req := newReq(nil)
209		s.mrRangeDiff(rr, req)
210		if rr.Code != 404 {
211			t.Fatalf("status %d, want 404 (never 403), body %s", rr.Code, rr.Body.String())
212		}
213		if strings.Contains(rr.Body.String(), title) {
214			t.Errorf("404 body leaks the MR title %q:\n%s", title, rr.Body.String())
215		}
216	})
217
218	t.Run("a signed-in non-reader gets 404 and no title", func(t *testing.T) {
219		rr, req := newReq(loginCookie(t, st, bob.ID))
220		s.mrRangeDiff(rr, req)
221		if rr.Code != 404 {
222			t.Fatalf("status %d, want 404 (never 403), body %s", rr.Code, rr.Body.String())
223		}
224		if strings.Contains(rr.Body.String(), title) {
225			t.Errorf("404 body leaks the MR title %q:\n%s", title, rr.Body.String())
226		}
227	})
228}
229
230// --from/--to reach the control command as real argv: an unknown
231// revision renders the command's refusal on the page, and two real
232// revisions render the range-diff between exactly those two.
233func TestMRRangeDiffPageFromToQuery(t *testing.T) {
234	st, cfg, alice, _, repo, n, v1, v2, _ := rangeDiffFixture(t)
235	s := New(cfg, st, nil)
236	cookie := loginCookie(t, st, alice.ID)
237
238	newReq := func(query string) (*httptest.ResponseRecorder, *http.Request) {
239		req := httptest.NewRequest("GET", "/alice/secret/mrs/"+strconv.FormatInt(n, 10)+"/range-diff"+query, nil)
240		req.SetPathValue("owner", repo.OwnerName)
241		req.SetPathValue("repo", repo.Name)
242		req.SetPathValue("n", strconv.FormatInt(n, 10))
243		req.AddCookie(cookie)
244		return httptest.NewRecorder(), req
245	}
246
247	// A bad --from/--to is a query parameter, not an unknown merge
248	// request: it renders the command's refusal inline rather than
249	// 404ing the page, which is reserved for an MR that does not exist
250	// (#271).
251	t.Run("unknown revision renders the refusal inline", func(t *testing.T) {
252		rr, req := newReq("?from=0000000000000000000000000000000000000000")
253		s.mrRangeDiff(rr, req)
254		if rr.Code != 200 {
255			t.Fatalf("status %d, want 200 (the refusal renders on the page), body %s", rr.Code, rr.Body.String())
256		}
257		if !strings.Contains(rr.Body.String(), "is not a revision of") {
258			t.Errorf("page does not show the refusal:\n%s", rr.Body.String())
259		}
260	})
261
262	t.Run("two real revisions render the diff between them", func(t *testing.T) {
263		rr, req := newReq("?from=" + v1 + "&to=" + v2)
264		s.mrRangeDiff(rr, req)
265		if rr.Code != 200 {
266			t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
267		}
268		body := rr.Body.String()
269		if !strings.Contains(body, "beta revised") {
270			t.Errorf("range-diff does not show the changed line:\n%s", body)
271		}
272	})
273
274	t.Run("the same revision twice is a usage error rendered inline", func(t *testing.T) {
275		rr, req := newReq("?from=" + v1 + "&to=" + v1)
276		s.mrRangeDiff(rr, req)
277		if rr.Code != 200 {
278			t.Fatalf("status %d, want 200 (the error renders on the page), body %s", rr.Code, rr.Body.String())
279		}
280		if !strings.Contains(rr.Body.String(), "same revision") {
281			t.Errorf("page does not show the usage error:\n%s", rr.Body.String())
282		}
283	})
284}
285
286// mrRangeDiffPageData mirrors the anonymous struct mrRangeDiff renders
287// with, the way mrPageData mirrors mr's in mrpage_test.go:16-40.
288type mrRangeDiffPageData struct {
289	repoPage
290	MR    store.MR
291	Diff  string
292	Error string
293}
294
295func testRangeDiffMR() store.MR {
296	return store.MR{Number: 7, Title: "org native rendering", Author: "cmc"}
297}
298
299// The diff branch renders the command's raw text output, HTML-escaped —
300// it is not markup, and must not be treated as any.
301func TestMRRangeDiffTemplateEscapesDiff(t *testing.T) {
302	var sb strings.Builder
303	if err := web.Render(&sb, "mrrangediff.html", mrRangeDiffPageData{
304		repoPage: testRepoPage(), MR: testRangeDiffMR(),
305		Diff: `<script>alert("x")&</script>`,
306	}); err != nil {
307		t.Fatalf("render: %v", err)
308	}
309	out := sb.String()
310	if strings.Contains(out, "<script>") {
311		t.Errorf("diff output was not escaped:\n%s", out)
312	}
313	if !strings.Contains(out, "&lt;script&gt;") || !strings.Contains(out, "&amp;") {
314		t.Errorf("diff output is missing its escaped form:\n%s", out)
315	}
316}
317
318// One revision has nothing to compare; the page says so rather than
319// showing an empty <pre>.
320func TestMRRangeDiffTemplateOneRevision(t *testing.T) {
321	var sb strings.Builder
322	if err := web.Render(&sb, "mrrangediff.html", mrRangeDiffPageData{
323		repoPage: testRepoPage(), MR: testRangeDiffMR(),
324	}); err != nil {
325		t.Fatalf("render: %v", err)
326	}
327	out := sb.String()
328	if !strings.Contains(out, "Nothing to compare") {
329		t.Errorf("empty diff does not explain there is nothing to compare:\n%s", out)
330	}
331	if strings.Contains(out, `<pre class="code`) {
332		t.Errorf("empty diff still rendered a pre block:\n%s", out)
333	}
334}
335
336// A command refusal (same revision twice, in production) renders as a
337// page error, not a diff.
338func TestMRRangeDiffTemplateError(t *testing.T) {
339	var sb strings.Builder
340	if err := web.Render(&sb, "mrrangediff.html", mrRangeDiffPageData{
341		repoPage: testRepoPage(), MR: testRangeDiffMR(),
342		Error: "--from and --to are the same revision",
343	}); err != nil {
344		t.Fatalf("render: %v", err)
345	}
346	out := sb.String()
347	if !strings.Contains(out, "same revision") {
348		t.Errorf("error was not rendered:\n%s", out)
349	}
350	if strings.Contains(out, `<pre class="code`) {
351		t.Errorf("error state still rendered a diff block:\n%s", out)
352	}
353}