repos_test.go
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/PuerkitoBio/goquery" |
| 13 | ) |
| 14 | |
| 15 | // repoAttrs returns the named attribute of every element matching sel. |
| 16 | func repoAttrs(r *response, sel, name string) []string { |
| 17 | var out []string |
| 18 | r.Find(sel).Each(func(_ int, s *goquery.Selection) { |
| 19 | v, _ := s.Attr(name) |
| 20 | out = append(out, v) |
| 21 | }) |
| 22 | return out |
| 23 | } |
| 24 | |
| 25 | // repoInitBare creates a bare repository directly on disk, bypassing the web |
| 26 | // form. The startup scan is what registers it. |
| 27 | func repoInitBare(t *testing.T, dir string) { |
| 28 | t.Helper() |
| 29 | if err := os.RemoveAll(dir); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | gitRun(t, filepath.Dir(dir), "init", "--bare", dir) |
| 33 | } |
| 34 | |
| 35 | func TestRepos(t *testing.T) { |
| 36 | e := newEnv(t) |
| 37 | admin := e.admin() |
| 38 | alice := e.register("alice", "password123") |
| 39 | |
| 40 | // Set by the commit log test and read by every commit detail test. |
| 41 | var commitURL string |
| 42 | |
| 43 | t.Run("non-admin gets 403 on /new", func(t *testing.T) { |
| 44 | alice.get("/new").mustStatus(http.StatusForbidden) |
| 45 | }) |
| 46 | |
| 47 | t.Run("create repository", func(t *testing.T) { |
| 48 | admin.post("/new", url.Values{ |
| 49 | "name": {"my-repo"}, "description": {"A test repo"}, "default_branch": {"main"}, |
| 50 | }).mustRedirect("/my-repo") |
| 51 | if !admin.get("/my-repo").Has(".empty-state") { |
| 52 | t.Error("empty-state missing on fresh repo") |
| 53 | } |
| 54 | }) |
| 55 | |
| 56 | t.Run("repository appears in list", func(t *testing.T) { |
| 57 | if names := admin.get("/").Texts(".repo-name"); !contains(names, "my-repo") { |
| 58 | t.Errorf("repo names = %v", names) |
| 59 | } |
| 60 | }) |
| 61 | |
| 62 | t.Run("search finds matching repo", func(t *testing.T) { |
| 63 | if names := admin.get("/?q=my-repo").Texts(".repo-name"); !contains(names, "my-repo") { |
| 64 | t.Errorf("repo names = %v", names) |
| 65 | } |
| 66 | }) |
| 67 | |
| 68 | t.Run("search returns empty for unknown term", func(t *testing.T) { |
| 69 | if !admin.get("/?q=zzz-nothing-here").Has(".empty-state") { |
| 70 | t.Error("empty-state missing for unknown search term") |
| 71 | } |
| 72 | }) |
| 73 | |
| 74 | t.Run("browse file tree after seeding content", func(t *testing.T) { |
| 75 | e.seedRepo("my-repo", nil) |
| 76 | files := admin.get("/my-repo/tree/main").Texts(".file-name a") |
| 77 | if !contains(files, "README.md") || !contains(files, "index.js") { |
| 78 | t.Errorf("files = %v", files) |
| 79 | } |
| 80 | }) |
| 81 | |
| 82 | t.Run("view file blob with syntax highlighting", func(t *testing.T) { |
| 83 | r := admin.get("/my-repo/blob/main/index.js") |
| 84 | if got := r.Text(".file-blob-name"); got != "index.js" { |
| 85 | t.Errorf("file name = %q", got) |
| 86 | } |
| 87 | if !r.Has(".file-blob-body") { |
| 88 | t.Error("file-blob-body missing") |
| 89 | } |
| 90 | }) |
| 91 | |
| 92 | t.Run("raw file download responds 200", func(t *testing.T) { |
| 93 | r := admin.get("/my-repo/raw/main/README.md").mustStatus(200) |
| 94 | if cd := r.Header.Get("Content-Disposition"); !strings.Contains(cd, "README.md") { |
| 95 | t.Errorf("Content-Disposition = %q", cd) |
| 96 | } |
| 97 | }) |
| 98 | |
| 99 | t.Run("raw svg is served as an image under a sandbox policy", func(t *testing.T) { |
| 100 | r := admin.get("/my-repo/raw/main/logo.svg").mustStatus(200) |
| 101 | if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") { |
| 102 | t.Errorf("Content-Type = %q", ct) |
| 103 | } |
| 104 | if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "sandbox;") { |
| 105 | t.Errorf("CSP = %q", csp) |
| 106 | } |
| 107 | txt := admin.get("/my-repo/raw/main/README.md") |
| 108 | if csp := txt.Header.Get("Content-Security-Policy"); strings.Contains(csp, "sandbox") { |
| 109 | t.Errorf("text file CSP = %q", csp) |
| 110 | } |
| 111 | }) |
| 112 | |
| 113 | t.Run("commit log shows initial commit", func(t *testing.T) { |
| 114 | r := admin.get("/my-repo/commits/main") |
| 115 | if subjects := r.Texts(".commit-subject"); !contains(subjects, "Initial commit") { |
| 116 | t.Errorf("subjects = %v", subjects) |
| 117 | } |
| 118 | commitURL = r.Attr(".commit-hash", "href") |
| 119 | if !strings.Contains(commitURL, "/my-repo/commit/") { |
| 120 | t.Fatalf("commit link = %q", commitURL) |
| 121 | } |
| 122 | }) |
| 123 | |
| 124 | t.Run("commit detail shows metadata card", func(t *testing.T) { |
| 125 | r := admin.get(commitURL) |
| 126 | if !r.Has(".commit-card") { |
| 127 | t.Fatal("commit-card missing") |
| 128 | } |
| 129 | if got := r.Text(".commit-card-subject"); !strings.Contains(got, "Initial commit") { |
| 130 | t.Errorf("subject = %q", got) |
| 131 | } |
| 132 | meta := r.Text(".commit-card-meta") |
| 133 | for _, want := range []string{"Test", "Author", "Date", "Commit"} { |
| 134 | if !strings.Contains(meta, want) { |
| 135 | t.Errorf("meta %q missing %q", meta, want) |
| 136 | } |
| 137 | } |
| 138 | }) |
| 139 | |
| 140 | t.Run("commit detail full SHA is shown", func(t *testing.T) { |
| 141 | sha := strings.TrimPrefix(commitURL, "/my-repo/commit/") |
| 142 | if got := admin.get(commitURL).Text(".commit-sha-full"); got != sha { |
| 143 | t.Errorf("sha = %q, want %q", got, sha) |
| 144 | } |
| 145 | }) |
| 146 | |
| 147 | t.Run("commit detail shows file nav sidebar", func(t *testing.T) { |
| 148 | r := admin.get(commitURL) |
| 149 | if !r.Has(".file-nav-details") { |
| 150 | t.Fatal("file-nav-details missing") |
| 151 | } |
| 152 | items := r.Texts(".file-nav-item") |
| 153 | if !contains(items, "README.md") || !contains(items, "index.js") { |
| 154 | t.Errorf("nav items = %v", items) |
| 155 | } |
| 156 | }) |
| 157 | |
| 158 | t.Run("commit detail file nav items are anchor links to diff sections", func(t *testing.T) { |
| 159 | hrefs := repoAttrs(admin.get(commitURL), ".file-nav-item", "href") |
| 160 | if len(hrefs) == 0 { |
| 161 | t.Fatal("no file nav items") |
| 162 | } |
| 163 | for _, h := range hrefs { |
| 164 | if !strings.HasPrefix(h, "#") { |
| 165 | t.Errorf("href = %q, want anchor", h) |
| 166 | } |
| 167 | } |
| 168 | }) |
| 169 | |
| 170 | t.Run("commit detail shows diff table with added lines", func(t *testing.T) { |
| 171 | r := admin.get(commitURL) |
| 172 | if !r.Has(".diff-table") { |
| 173 | t.Error("diff-table missing") |
| 174 | } |
| 175 | if n := r.Count(".diff-row-add"); n == 0 { |
| 176 | t.Error("no added rows") |
| 177 | } |
| 178 | if n := r.Count(".diff-row-del"); n != 0 { |
| 179 | t.Errorf("deleted rows = %d, want 0", n) |
| 180 | } |
| 181 | }) |
| 182 | |
| 183 | t.Run("commit detail diff table has line numbers", func(t *testing.T) { |
| 184 | r := admin.get(commitURL) |
| 185 | if got := r.Text(".diff-row-add .diff-ln-new"); got != "1" { |
| 186 | t.Errorf("first new line number = %q", got) |
| 187 | } |
| 188 | }) |
| 189 | |
| 190 | t.Run("commit detail shows added stats on file header", func(t *testing.T) { |
| 191 | stats := admin.get(commitURL).Texts(".diff-stat-add") |
| 192 | if len(stats) == 0 { |
| 193 | t.Fatal("no add stats") |
| 194 | } |
| 195 | for _, s := range stats { |
| 196 | if !strings.HasPrefix(s, "+") { |
| 197 | t.Errorf("stat = %q", s) |
| 198 | } |
| 199 | } |
| 200 | }) |
| 201 | |
| 202 | t.Run("commit detail view-at-sha button links to blob at that commit", func(t *testing.T) { |
| 203 | sha := strings.TrimPrefix(commitURL, "/my-repo/commit/") |
| 204 | href := admin.get(commitURL).Attr(".btn-xs", "href") |
| 205 | if !strings.Contains(href, "/blob/"+sha+"/") { |
| 206 | t.Errorf("href = %q", href) |
| 207 | } |
| 208 | }) |
| 209 | |
| 210 | t.Run("commit detail view-at-branch button links to blob at default branch", func(t *testing.T) { |
| 211 | r := admin.get(commitURL) |
| 212 | texts := r.Texts(".btn-xs") |
| 213 | if !contains(texts, "@ main") { |
| 214 | t.Fatalf("buttons = %v", texts) |
| 215 | } |
| 216 | found := false |
| 217 | r.Find(".btn-xs").Each(func(_ int, sel *goquery.Selection) { |
| 218 | if !strings.Contains(sel.Text(), "@ main") { |
| 219 | return |
| 220 | } |
| 221 | found = true |
| 222 | href, _ := sel.Attr("href") |
| 223 | if !strings.Contains(href, "/blob/main/") { |
| 224 | t.Errorf("branch button href = %q", href) |
| 225 | } |
| 226 | }) |
| 227 | if !found { |
| 228 | t.Error("no @ main button") |
| 229 | } |
| 230 | }) |
| 231 | |
| 232 | t.Run("commit detail file diff is open by default", func(t *testing.T) { |
| 233 | // Collapsing needs a browser click; only the initial state is checked. |
| 234 | if _, ok := admin.get(commitURL).Find(".diff-file").First().Attr("open"); !ok { |
| 235 | t.Error("diff-file is not open") |
| 236 | } |
| 237 | }) |
| 238 | |
| 239 | t.Run("commit detail file nav sidebar is open by default", func(t *testing.T) { |
| 240 | if _, ok := admin.get(commitURL).Find(".file-nav-details").First().Attr("open"); !ok { |
| 241 | t.Error("file-nav-details is not open") |
| 242 | } |
| 243 | }) |
| 244 | |
| 245 | t.Run("readme renders on repo home", func(t *testing.T) { |
| 246 | r := admin.get("/my-repo") |
| 247 | if !r.Has(".readme-header") { |
| 248 | t.Error("readme-header missing") |
| 249 | } |
| 250 | if html, _ := r.Find(".readme-section .markdown-body").Html(); !strings.Contains(html, "my-repo") { |
| 251 | t.Errorf("readme html = %q", html) |
| 252 | } |
| 253 | }) |
| 254 | |
| 255 | t.Run("private repo hidden from other users", func(t *testing.T) { |
| 256 | r := admin.follow(admin.post("/my-repo/settings", url.Values{"is_private": {"1"}})) |
| 257 | if !r.Has(".form-success") { |
| 258 | t.Fatal("form-success missing after saving settings") |
| 259 | } |
| 260 | |
| 261 | alice.get("/my-repo").mustStatus(http.StatusNotFound) |
| 262 | if names := alice.get("/").Texts(".repo-name"); contains(names, "my-repo") { |
| 263 | t.Errorf("private repo listed for alice: %v", names) |
| 264 | } |
| 265 | |
| 266 | admin.post("/my-repo/settings", url.Values{}).mustRedirect("/my-repo/settings") |
| 267 | }) |
| 268 | |
| 269 | t.Run("settings tab visible for admin, hidden for others", func(t *testing.T) { |
| 270 | if !admin.get("/my-repo").Has(`.repo-tab[href$="/settings"]`) { |
| 271 | t.Error("settings tab missing for admin") |
| 272 | } |
| 273 | if n := alice.get("/my-repo").Count(`.repo-tab[href$="/settings"]`); n != 0 { |
| 274 | t.Errorf("settings tabs for alice = %d", n) |
| 275 | } |
| 276 | }) |
| 277 | |
| 278 | t.Run("create repository with invalid name shows error", func(t *testing.T) { |
| 279 | r := admin.post("/new", url.Values{ |
| 280 | "name": {"has spaces!"}, "description": {""}, "default_branch": {"main"}, |
| 281 | }).mustStatus(200) |
| 282 | if !r.Contains("Invalid repository name") { |
| 283 | t.Error("error message missing") |
| 284 | } |
| 285 | }) |
| 286 | |
| 287 | t.Run("auto-scanned repo is private by default", func(t *testing.T) { |
| 288 | const name = "auto-private-repo" |
| 289 | dir := e.repoPath(name) |
| 290 | repoInitBare(t, dir) |
| 291 | work := t.TempDir() |
| 292 | gitRun(t, work, "clone", "-q", dir, ".") |
| 293 | gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "init") |
| 294 | gitRun(t, work, "push", "-q", "origin", "HEAD:main") |
| 295 | |
| 296 | // The server adopts repos found on disk at startup, not per request. |
| 297 | if err := e.Srv.SyncRepos(context.Background()); err != nil { |
| 298 | t.Fatal(err) |
| 299 | } |
| 300 | repo, err := e.DB.RepoByName(context.Background(), name) |
| 301 | if err != nil { |
| 302 | t.Fatal(err) |
| 303 | } |
| 304 | if repo == nil || !repo.IsPrivate { |
| 305 | t.Fatalf("repo = %+v, want private", repo) |
| 306 | } |
| 307 | |
| 308 | if err := e.DB.DeleteRepoByName(context.Background(), name); err != nil { |
| 309 | t.Fatal(err) |
| 310 | } |
| 311 | os.RemoveAll(dir) |
| 312 | }) |
| 313 | |
| 314 | t.Run("repo is registered even with a stale config.lock", func(t *testing.T) { |
| 315 | const name = "stale-lock-repo" |
| 316 | dir := e.repoPath(name) |
| 317 | repoInitBare(t, dir) |
| 318 | // Drop core.bare so the startup scan has to attempt a write, then |
| 319 | // block that write with a leftover lock file. |
| 320 | gitRun(t, dir, "config", "--file", filepath.Join(dir, "config"), "--unset", "core.bare") |
| 321 | if err := os.WriteFile(filepath.Join(dir, "config.lock"), nil, 0o644); err != nil { |
| 322 | t.Fatal(err) |
| 323 | } |
| 324 | |
| 325 | if err := e.Srv.SyncRepos(context.Background()); err != nil { |
| 326 | t.Fatal(err) |
| 327 | } |
| 328 | repo, err := e.DB.RepoByName(context.Background(), name) |
| 329 | if err != nil { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | if repo == nil || repo.Name != name { |
| 333 | t.Fatalf("repo = %+v, want %q", repo, name) |
| 334 | } |
| 335 | |
| 336 | if err := e.DB.DeleteRepoByName(context.Background(), name); err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | os.RemoveAll(dir) |
| 340 | }) |
| 341 | } |
| 342 |