repos_test.go
⎇
Raw
1package e2e
2
3import (
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.
16func 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.
27func 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
35func 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