| @@ -0,0 +1,346 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 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). |
| 22 | func 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) |
| 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. |
| 57 | func 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 | |
| 64 | func 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. |
| 85 | func 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. |
| 165 | func 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). |
| 181 | func TestMRRangeDiffPagePrivateRepo(t *testing.T) { |
| 182 | st, cfg, alice, bob, repo, n, _, _, title := rangeDiffFixture(t) |
| 183 | s := New(cfg, st) |
| 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 is refused with not-found, and two real revisions render the |
| 232 | // range-diff between exactly those two. |
| 233 | func TestMRRangeDiffPageFromToQuery(t *testing.T) { |
| 234 | st, cfg, alice, _, repo, n, v1, v2, _ := rangeDiffFixture(t) |
| 235 | s := New(cfg, st) |
| 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 | t.Run("unknown revision is 404", func(t *testing.T) { |
| 248 | rr, req := newReq("?from=0000000000000000000000000000000000000000") |
| 249 | s.mrRangeDiff(rr, req) |
| 250 | if rr.Code != 404 { |
| 251 | t.Fatalf("status %d, want 404, body %s", rr.Code, rr.Body.String()) |
| 252 | } |
| 253 | }) |
| 254 | |
| 255 | t.Run("two real revisions render the diff between them", func(t *testing.T) { |
| 256 | rr, req := newReq("?from=" + v1 + "&to=" + v2) |
| 257 | s.mrRangeDiff(rr, req) |
| 258 | if rr.Code != 200 { |
| 259 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) |
| 260 | } |
| 261 | body := rr.Body.String() |
| 262 | if !strings.Contains(body, "beta revised") { |
| 263 | t.Errorf("range-diff does not show the changed line:\n%s", body) |
| 264 | } |
| 265 | }) |
| 266 | |
| 267 | t.Run("the same revision twice is a usage error rendered inline", func(t *testing.T) { |
| 268 | rr, req := newReq("?from=" + v1 + "&to=" + v1) |
| 269 | s.mrRangeDiff(rr, req) |
| 270 | if rr.Code != 200 { |
| 271 | t.Fatalf("status %d, want 200 (the error renders on the page), body %s", rr.Code, rr.Body.String()) |
| 272 | } |
| 273 | if !strings.Contains(rr.Body.String(), "same revision") { |
| 274 | t.Errorf("page does not show the usage error:\n%s", rr.Body.String()) |
| 275 | } |
| 276 | }) |
| 277 | } |
| 278 | |
| 279 | // mrRangeDiffPageData mirrors the anonymous struct mrRangeDiff renders |
| 280 | // with, the way mrPageData mirrors mr's in mrpage_test.go:16-40. |
| 281 | type mrRangeDiffPageData struct { |
| 282 | repoPage |
| 283 | MR store.MR |
| 284 | Diff string |
| 285 | Error string |
| 286 | } |
| 287 | |
| 288 | func testRangeDiffMR() store.MR { |
| 289 | return store.MR{Number: 7, Title: "org native rendering", Author: "cmc"} |
| 290 | } |
| 291 | |
| 292 | // The diff branch renders the command's raw text output, HTML-escaped — |
| 293 | // it is not markup, and must not be treated as any. |
| 294 | func TestMRRangeDiffTemplateEscapesDiff(t *testing.T) { |
| 295 | var sb strings.Builder |
| 296 | if err := web.Render(&sb, "mrrangediff.html", mrRangeDiffPageData{ |
| 297 | repoPage: testRepoPage(), MR: testRangeDiffMR(), |
| 298 | Diff: `<script>alert("x")&</script>`, |
| 299 | }); err != nil { |
| 300 | t.Fatalf("render: %v", err) |
| 301 | } |
| 302 | out := sb.String() |
| 303 | if strings.Contains(out, "<script>") { |
| 304 | t.Errorf("diff output was not escaped:\n%s", out) |
| 305 | } |
| 306 | if !strings.Contains(out, "<script>") || !strings.Contains(out, "&") { |
| 307 | t.Errorf("diff output is missing its escaped form:\n%s", out) |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | // One revision has nothing to compare; the page says so rather than |
| 312 | // showing an empty <pre>. |
| 313 | func TestMRRangeDiffTemplateOneRevision(t *testing.T) { |
| 314 | var sb strings.Builder |
| 315 | if err := web.Render(&sb, "mrrangediff.html", mrRangeDiffPageData{ |
| 316 | repoPage: testRepoPage(), MR: testRangeDiffMR(), |
| 317 | }); err != nil { |
| 318 | t.Fatalf("render: %v", err) |
| 319 | } |
| 320 | out := sb.String() |
| 321 | if !strings.Contains(out, "Nothing to compare") { |
| 322 | t.Errorf("empty diff does not explain there is nothing to compare:\n%s", out) |
| 323 | } |
| 324 | if strings.Contains(out, `<pre class="code`) { |
| 325 | t.Errorf("empty diff still rendered a pre block:\n%s", out) |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | // A command refusal (same revision twice, in production) renders as a |
| 330 | // page error, not a diff. |
| 331 | func TestMRRangeDiffTemplateError(t *testing.T) { |
| 332 | var sb strings.Builder |
| 333 | if err := web.Render(&sb, "mrrangediff.html", mrRangeDiffPageData{ |
| 334 | repoPage: testRepoPage(), MR: testRangeDiffMR(), |
| 335 | Error: "--from and --to are the same revision", |
| 336 | }); err != nil { |
| 337 | t.Fatalf("render: %v", err) |
| 338 | } |
| 339 | out := sb.String() |
| 340 | if !strings.Contains(out, "same revision") { |
| 341 | t.Errorf("error was not rendered:\n%s", out) |
| 342 | } |
| 343 | if strings.Contains(out, `<pre class="code`) { |
| 344 | t.Errorf("error state still rendered a diff block:\n%s", out) |
| 345 | } |
| 346 | } |