internal/httpd/mrrangediff_test.go
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, "<script>") || !strings.Contains(out, "&") {
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}