package e2e import ( "context" "net/http" "net/url" "os" "path/filepath" "strings" "testing" "github.com/PuerkitoBio/goquery" ) // repoAttrs returns the named attribute of every element matching sel. func repoAttrs(r *response, sel, name string) []string { var out []string r.Find(sel).Each(func(_ int, s *goquery.Selection) { v, _ := s.Attr(name) out = append(out, v) }) return out } // repoInitBare creates a bare repository directly on disk, bypassing the web // form. The startup scan is what registers it. func repoInitBare(t *testing.T, dir string) { t.Helper() if err := os.RemoveAll(dir); err != nil { t.Fatal(err) } gitRun(t, filepath.Dir(dir), "init", "--bare", dir) } func TestRepos(t *testing.T) { e := newEnv(t) admin := e.admin() alice := e.register("alice", "password123") // Set by the commit log test and read by every commit detail test. var commitURL string t.Run("non-admin gets 403 on /new", func(t *testing.T) { alice.get("/new").mustStatus(http.StatusForbidden) }) t.Run("create repository", func(t *testing.T) { admin.post("/new", url.Values{ "name": {"my-repo"}, "description": {"A test repo"}, "default_branch": {"main"}, }).mustRedirect("/my-repo") if !admin.get("/my-repo").Has(".empty-state") { t.Error("empty-state missing on fresh repo") } }) t.Run("repository appears in list", func(t *testing.T) { if names := admin.get("/").Texts(".repo-name"); !contains(names, "my-repo") { t.Errorf("repo names = %v", names) } }) t.Run("search finds matching repo", func(t *testing.T) { if names := admin.get("/?q=my-repo").Texts(".repo-name"); !contains(names, "my-repo") { t.Errorf("repo names = %v", names) } }) t.Run("search returns empty for unknown term", func(t *testing.T) { if !admin.get("/?q=zzz-nothing-here").Has(".empty-state") { t.Error("empty-state missing for unknown search term") } }) t.Run("browse file tree after seeding content", func(t *testing.T) { e.seedRepo("my-repo", nil) files := admin.get("/my-repo/tree/main").Texts(".file-name a") if !contains(files, "README.md") || !contains(files, "index.js") { t.Errorf("files = %v", files) } }) t.Run("view file blob with syntax highlighting", func(t *testing.T) { r := admin.get("/my-repo/blob/main/index.js") if got := r.Text(".file-blob-name"); got != "index.js" { t.Errorf("file name = %q", got) } if !r.Has(".file-blob-body") { t.Error("file-blob-body missing") } }) t.Run("raw file download responds 200", func(t *testing.T) { r := admin.get("/my-repo/raw/main/README.md").mustStatus(200) if cd := r.Header.Get("Content-Disposition"); !strings.Contains(cd, "README.md") { t.Errorf("Content-Disposition = %q", cd) } }) t.Run("raw svg is served as an image under a sandbox policy", func(t *testing.T) { r := admin.get("/my-repo/raw/main/logo.svg").mustStatus(200) if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") { t.Errorf("Content-Type = %q", ct) } if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "sandbox;") { t.Errorf("CSP = %q", csp) } txt := admin.get("/my-repo/raw/main/README.md") if csp := txt.Header.Get("Content-Security-Policy"); strings.Contains(csp, "sandbox") { t.Errorf("text file CSP = %q", csp) } }) t.Run("commit log shows initial commit", func(t *testing.T) { r := admin.get("/my-repo/commits/main") if subjects := r.Texts(".commit-subject"); !contains(subjects, "Initial commit") { t.Errorf("subjects = %v", subjects) } commitURL = r.Attr(".commit-hash", "href") if !strings.Contains(commitURL, "/my-repo/commit/") { t.Fatalf("commit link = %q", commitURL) } }) t.Run("commit detail shows metadata card", func(t *testing.T) { r := admin.get(commitURL) if !r.Has(".commit-card") { t.Fatal("commit-card missing") } if got := r.Text(".commit-card-subject"); !strings.Contains(got, "Initial commit") { t.Errorf("subject = %q", got) } meta := r.Text(".commit-card-meta") for _, want := range []string{"Test", "Author", "Date", "Commit"} { if !strings.Contains(meta, want) { t.Errorf("meta %q missing %q", meta, want) } } }) t.Run("commit detail full SHA is shown", func(t *testing.T) { sha := strings.TrimPrefix(commitURL, "/my-repo/commit/") if got := admin.get(commitURL).Text(".commit-sha-full"); got != sha { t.Errorf("sha = %q, want %q", got, sha) } }) t.Run("commit detail shows file nav sidebar", func(t *testing.T) { r := admin.get(commitURL) if !r.Has(".file-nav-details") { t.Fatal("file-nav-details missing") } items := r.Texts(".file-nav-item") if !contains(items, "README.md") || !contains(items, "index.js") { t.Errorf("nav items = %v", items) } }) t.Run("commit detail file nav items are anchor links to diff sections", func(t *testing.T) { hrefs := repoAttrs(admin.get(commitURL), ".file-nav-item", "href") if len(hrefs) == 0 { t.Fatal("no file nav items") } for _, h := range hrefs { if !strings.HasPrefix(h, "#") { t.Errorf("href = %q, want anchor", h) } } }) t.Run("commit detail shows diff table with added lines", func(t *testing.T) { r := admin.get(commitURL) if !r.Has(".diff-table") { t.Error("diff-table missing") } if n := r.Count(".diff-row-add"); n == 0 { t.Error("no added rows") } if n := r.Count(".diff-row-del"); n != 0 { t.Errorf("deleted rows = %d, want 0", n) } }) t.Run("commit detail diff table has line numbers", func(t *testing.T) { r := admin.get(commitURL) if got := r.Text(".diff-row-add .diff-ln-new"); got != "1" { t.Errorf("first new line number = %q", got) } }) t.Run("commit detail shows added stats on file header", func(t *testing.T) { stats := admin.get(commitURL).Texts(".diff-stat-add") if len(stats) == 0 { t.Fatal("no add stats") } for _, s := range stats { if !strings.HasPrefix(s, "+") { t.Errorf("stat = %q", s) } } }) t.Run("commit detail view-at-sha button links to blob at that commit", func(t *testing.T) { sha := strings.TrimPrefix(commitURL, "/my-repo/commit/") href := admin.get(commitURL).Attr(".btn-xs", "href") if !strings.Contains(href, "/blob/"+sha+"/") { t.Errorf("href = %q", href) } }) t.Run("commit detail view-at-branch button links to blob at default branch", func(t *testing.T) { r := admin.get(commitURL) texts := r.Texts(".btn-xs") if !contains(texts, "@ main") { t.Fatalf("buttons = %v", texts) } found := false r.Find(".btn-xs").Each(func(_ int, sel *goquery.Selection) { if !strings.Contains(sel.Text(), "@ main") { return } found = true href, _ := sel.Attr("href") if !strings.Contains(href, "/blob/main/") { t.Errorf("branch button href = %q", href) } }) if !found { t.Error("no @ main button") } }) t.Run("commit detail file diff is open by default", func(t *testing.T) { // Collapsing needs a browser click; only the initial state is checked. if _, ok := admin.get(commitURL).Find(".diff-file").First().Attr("open"); !ok { t.Error("diff-file is not open") } }) t.Run("commit detail file nav sidebar is open by default", func(t *testing.T) { if _, ok := admin.get(commitURL).Find(".file-nav-details").First().Attr("open"); !ok { t.Error("file-nav-details is not open") } }) t.Run("readme renders on repo home", func(t *testing.T) { r := admin.get("/my-repo") if !r.Has(".readme-header") { t.Error("readme-header missing") } if html, _ := r.Find(".readme-section .markdown-body").Html(); !strings.Contains(html, "my-repo") { t.Errorf("readme html = %q", html) } }) t.Run("private repo hidden from other users", func(t *testing.T) { r := admin.follow(admin.post("/my-repo/settings", url.Values{"is_private": {"1"}})) if !r.Has(".form-success") { t.Fatal("form-success missing after saving settings") } alice.get("/my-repo").mustStatus(http.StatusNotFound) if names := alice.get("/").Texts(".repo-name"); contains(names, "my-repo") { t.Errorf("private repo listed for alice: %v", names) } admin.post("/my-repo/settings", url.Values{}).mustRedirect("/my-repo/settings") }) t.Run("settings tab visible for admin, hidden for others", func(t *testing.T) { if !admin.get("/my-repo").Has(`.repo-tab[href$="/settings"]`) { t.Error("settings tab missing for admin") } if n := alice.get("/my-repo").Count(`.repo-tab[href$="/settings"]`); n != 0 { t.Errorf("settings tabs for alice = %d", n) } }) t.Run("create repository with invalid name shows error", func(t *testing.T) { r := admin.post("/new", url.Values{ "name": {"has spaces!"}, "description": {""}, "default_branch": {"main"}, }).mustStatus(200) if !r.Contains("Invalid repository name") { t.Error("error message missing") } }) t.Run("auto-scanned repo is private by default", func(t *testing.T) { const name = "auto-private-repo" dir := e.repoPath(name) repoInitBare(t, dir) work := t.TempDir() gitRun(t, work, "clone", "-q", dir, ".") gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "init") gitRun(t, work, "push", "-q", "origin", "HEAD:main") // The server adopts repos found on disk at startup, not per request. if err := e.Srv.SyncRepos(context.Background()); err != nil { t.Fatal(err) } repo, err := e.DB.RepoByName(context.Background(), name) if err != nil { t.Fatal(err) } if repo == nil || !repo.IsPrivate { t.Fatalf("repo = %+v, want private", repo) } if err := e.DB.DeleteRepoByName(context.Background(), name); err != nil { t.Fatal(err) } os.RemoveAll(dir) }) t.Run("repo is registered even with a stale config.lock", func(t *testing.T) { const name = "stale-lock-repo" dir := e.repoPath(name) repoInitBare(t, dir) // Drop core.bare so the startup scan has to attempt a write, then // block that write with a leftover lock file. gitRun(t, dir, "config", "--file", filepath.Join(dir, "config"), "--unset", "core.bare") if err := os.WriteFile(filepath.Join(dir, "config.lock"), nil, 0o644); err != nil { t.Fatal(err) } if err := e.Srv.SyncRepos(context.Background()); err != nil { t.Fatal(err) } repo, err := e.DB.RepoByName(context.Background(), name) if err != nil { t.Fatal(err) } if repo == nil || repo.Name != name { t.Fatalf("repo = %+v, want %q", repo, name) } if err := e.DB.DeleteRepoByName(context.Background(), name); err != nil { t.Fatal(err) } os.RemoveAll(dir) }) }