package e2e import ( "net/url" "regexp" "slices" "strings" "testing" "github.com/PuerkitoBio/goquery" ) // gitopsCommitRedirect matches the redirect target of a write operation. var gitopsCommitRedirect = regexp.MustCompile(`^/[a-z-]+/commit/[0-9a-f]{40}$`) // gitopsRefNames lists the branch or tag names on a ref list page. func gitopsRefNames(r *response) []string { return r.Texts(".ref-item .ref-name") } // gitopsRefItem returns the list row of one branch or tag, or nil. func gitopsRefItem(r *response, name string) *goquery.Selection { var found *goquery.Selection r.Find(".ref-item").EachWithBreak(func(_ int, s *goquery.Selection) bool { if strings.TrimSpace(s.Find(".ref-name").First().Text()) == name { found = s return false } return true }) return found } // gitopsSummaries lists the popup toggle labels inside a row. func gitopsSummaries(sel *goquery.Selection) []string { var out []string sel.Find("summary").Each(func(_ int, s *goquery.Selection) { out = append(out, strings.TrimSpace(s.Text())) }) return out } // gitopsCommitBody returns the raw commit object of the newest commit whose // message matches grep. func gitopsCommitBody(t *testing.T, dir, grep string) string { t.Helper() hash := gitRun(t, dir, "log", "--format=%H", "--grep="+grep, "-1") if hash == "" { t.Fatalf("no commit matching %q", grep) } return gitRun(t, dir, "cat-file", "-p", hash) } func TestGitOps(t *testing.T) { e := newEnv(t) admin := e.admin() for _, name := range []string{ "branch-repo", "tag-repo", "selector-repo", "newfile-repo", "delfile-repo", "movefile-repo", } { e.createRepo(admin, name) } e.seedRepo("branch-repo", nil) e.seedBranch("branch-repo", "feature-a", nil) e.seedRepo("tag-repo", nil) gitRun(t, e.repoPath("tag-repo"), "tag", "v0.1.0", "HEAD") e.Srv.Git.InvalidateRefCache("tag-repo") e.seedRepo("selector-repo", nil) gitRun(t, e.repoPath("selector-repo"), "tag", "stable", "HEAD") e.Srv.Git.InvalidateRefCache("selector-repo") e.seedRepo("newfile-repo", nil) e.seedRepo("delfile-repo", nil) e.seedRepo("movefile-repo", nil) // ─── Branch management ─────────────────────────────────────────────── t.Run("Branches tab appears in repo nav", func(t *testing.T) { tabs := admin.get("/branch-repo").mustStatus(200).Texts("a.repo-tab") if !slices.Contains(tabs, "Branches") { t.Errorf("repo tabs = %v", tabs) } }) t.Run("branches page lists all branches", func(t *testing.T) { r := admin.get("/branch-repo/branches").mustStatus(200) if !r.Contains("main") || !r.Contains("feature-a") { t.Error("branch list is missing main or feature-a") } }) t.Run("default branch has a \"default\" badge", func(t *testing.T) { badges := admin.get("/branch-repo/branches").Texts(".badge") if !slices.Contains(badges, "default") { t.Errorf("badges = %v", badges) } }) t.Run("branch list shows last commit hash and subject", func(t *testing.T) { sha := e.headCommit("branch-repo")[:7] r := admin.get("/branch-repo/branches") if !r.Contains(sha) || !r.Contains("Initial commit") { t.Errorf("branch list misses %q or the subject", sha) } }) t.Run("branches page returns 404 for non-existent repo", func(t *testing.T) { e.anon().get("/does-not-exist/branches").mustStatus(404) }) t.Run("create a new branch", func(t *testing.T) { r := admin.post("/branch-repo/branches/create", url.Values{ "name": {"new-feature"}, "source_ref": {"main"}, }) r.mustRedirect("/branch-repo/branches") names := gitopsRefNames(admin.follow(r)) if !slices.Contains(names, "new-feature") { t.Errorf("branches = %v", names) } }) t.Run("creating branch with invalid name shows error", func(t *testing.T) { loc := admin.post("/branch-repo/branches/create", url.Values{ "name": {"--invalid"}, "source_ref": {"main"}, }).mustRedirect("/branch-repo/branches") if !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) t.Run("creating branch from non-existent ref shows error", func(t *testing.T) { loc := admin.post("/branch-repo/branches/create", url.Values{ "name": {"bad-branch"}, "source_ref": {"does-not-exist"}, }).mustRedirect("/branch-repo/branches") if !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) t.Run("rename a branch", func(t *testing.T) { r := admin.post("/branch-repo/branches/rename", url.Values{ "old_name": {"feature-a"}, "new_name": {"feature-renamed"}, }) r.mustRedirect("/branch-repo/branches") names := gitopsRefNames(admin.follow(r)) if !slices.Contains(names, "feature-renamed") || slices.Contains(names, "feature-a") { t.Errorf("branches = %v", names) } }) t.Run("delete a non-default branch", func(t *testing.T) { r := admin.post("/branch-repo/branches/delete", url.Values{"name": {"new-feature"}}) r.mustRedirect("/branch-repo/branches") names := gitopsRefNames(admin.follow(r)) if slices.Contains(names, "new-feature") { t.Errorf("branches = %v", names) } }) t.Run("cannot delete the default branch (no Delete button on main row)", func(t *testing.T) { row := gitopsRefItem(admin.get("/branch-repo/branches"), "main") if row == nil { t.Fatal("main row missing") } if labels := gitopsSummaries(row); slices.Contains(labels, "Delete") { t.Errorf("main row actions = %v", labels) } }) t.Run("renaming default branch updates it in repo", func(t *testing.T) { r := admin.post("/branch-repo/branches/rename", url.Values{ "old_name": {"main"}, "new_name": {"trunk"}, }) r.mustRedirect("/branch-repo/branches") row := gitopsRefItem(admin.follow(r), "trunk") if row == nil { t.Fatal("trunk row missing") } if row.Find(".badge").Length() == 0 { t.Error("trunk is not marked as the default branch") } // Rename back so later reads of this repo still see main. admin.post("/branch-repo/branches/rename", url.Values{ "old_name": {"trunk"}, "new_name": {"main"}, }).mustRedirect("/branch-repo/branches") }) // ─── Tag management ────────────────────────────────────────────────── t.Run("Tags tab appears in repo nav", func(t *testing.T) { tabs := admin.get("/tag-repo").mustStatus(200).Texts("a.repo-tab") if !slices.Contains(tabs, "Tags") { t.Errorf("repo tabs = %v", tabs) } }) t.Run("tags page lists existing tags", func(t *testing.T) { if !admin.get("/tag-repo/tags").mustStatus(200).Contains("v0.1.0") { t.Error("v0.1.0 missing") } }) t.Run("tag links to correct tree view", func(t *testing.T) { r := admin.get("/tag-repo/tags") if r.Count(`a[href="/tag-repo/tree/v0.1.0"]`) == 0 { t.Error("tree link for v0.1.0 missing") } }) t.Run("create a new tag", func(t *testing.T) { r := admin.post("/tag-repo/tags/create", url.Values{ "name": {"v1.0.0"}, "ref": {"main"}, }) r.mustRedirect("/tag-repo/tags") names := gitopsRefNames(admin.follow(r)) if !slices.Contains(names, "v1.0.0") { t.Errorf("tags = %v", names) } }) t.Run("create annotated tag with message", func(t *testing.T) { r := admin.post("/tag-repo/tags/create", url.Values{ "name": {"v1.1.0-annotated"}, "ref": {"main"}, "message": {"Annotated release tag"}, }) r.mustRedirect("/tag-repo/tags") names := gitopsRefNames(admin.follow(r)) if !slices.Contains(names, "v1.1.0-annotated") { t.Errorf("tags = %v", names) } }) t.Run("delete a tag", func(t *testing.T) { r := admin.post("/tag-repo/tags/delete", url.Values{"name": {"v1.0.0"}}) r.mustRedirect("/tag-repo/tags") names := gitopsRefNames(admin.follow(r)) if slices.Contains(names, "v1.0.0") || !slices.Contains(names, "v0.1.0") { t.Errorf("tags = %v", names) } }) t.Run("tag linked to release shows release badge and warning on delete", func(t *testing.T) { admin.postMultipart("/tag-repo/releases", url.Values{ "name": {"Linked Release"}, "create_tag": {"on"}, "tag_name": {"v-linked"}, "revision": {"main"}, }).mustRedirect("/tag-repo/releases") row := gitopsRefItem(admin.get("/tag-repo/tags"), "v-linked") if row == nil { t.Fatal("v-linked row missing") } if row.Find(".badge-release").Length() == 0 { t.Error("release badge missing") } if row.Find(".confirm-warning").Length() == 0 { t.Error("delete warning missing") } }) // ─── BranchSelector with tags ──────────────────────────────────────── t.Run("branch selector shows Tags optgroup when tags exist", func(t *testing.T) { r := admin.get("/selector-repo").mustStatus(200) if n := r.Count(`optgroup[label="Tags"]`); n != 1 { t.Errorf("Tags optgroups = %d", n) } if !contains(r.Texts("option"), "stable") { t.Error("stable option missing") } }) t.Run("branch selector shows Branches optgroup when tags exist", func(t *testing.T) { if n := admin.get("/selector-repo").Count(`optgroup[label="Branches"]`); n != 1 { t.Errorf("Branches optgroups = %d", n) } }) t.Run("branch selector on blob view includes tag optgroup", func(t *testing.T) { r := admin.get("/selector-repo/blob/main/README.md").mustStatus(200) if n := r.Count(`optgroup[label="Tags"]`); n != 1 { t.Errorf("Tags optgroups = %d", n) } }) // ─── File creation ─────────────────────────────────────────────────── t.Run("New file button appears in tree toolbar for admin on a branch", func(t *testing.T) { if admin.get("/newfile-repo/tree/main").mustStatus(200). Count(`a[href*="/new-file/main"]`) == 0 { t.Error("New file link missing") } }) t.Run("New file button not visible on commit SHA view", func(t *testing.T) { sha := e.headCommit("newfile-repo") if n := admin.get("/newfile-repo/tree/" + sha).Count(`a[href*="/new-file/"]`); n != 0 { t.Errorf("New file links = %d", n) } }) t.Run("New file button not visible to unauthenticated user", func(t *testing.T) { if n := e.anon().get("/newfile-repo/tree/main").Count(`a[href*="/new-file/main"]`); n != 0 { t.Errorf("New file links = %d", n) } }) t.Run("new file form pre-fills dir when ?dir= query param is provided", func(t *testing.T) { if got := admin.get("/newfile-repo/new-file/main?dir=src").Value("[name=path]"); got != "src/" { t.Errorf("path = %q", got) } }) t.Run("creating a new file creates a commit and redirects to commit view", func(t *testing.T) { loc := admin.post("/newfile-repo/new-file/main", url.Values{ "path": {"hello.txt"}, "content": {"Hello, world!\n"}, "message": {"Add hello.txt"}, }).mustRedirect("/newfile-repo/commit/") if !gitopsCommitRedirect.MatchString(loc) { t.Errorf("location = %q", loc) } }) t.Run("new file appears in tree after creation", func(t *testing.T) { if !admin.get("/newfile-repo/tree/main").Contains("hello.txt") { t.Error("hello.txt missing from tree") } }) t.Run("new file commit is signed", func(t *testing.T) { obj := gitopsCommitBody(t, e.repoPath("newfile-repo"), "Add hello.txt") if !strings.Contains(obj, "gpgsig") { t.Errorf("commit object has no gpgsig:\n%s", obj) } }) t.Run("creating file with invalid path shows error", func(t *testing.T) { loc := admin.post("/newfile-repo/new-file/main", url.Values{ "path": {"../escape"}, "content": {""}, "message": {"bad"}, }).mustRedirect("/newfile-repo/new-file/main") if !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) // ─── File deletion ─────────────────────────────────────────────────── t.Run("Delete button appears in file blob for admin on a branch", func(t *testing.T) { r := admin.get("/delfile-repo/blob/main/index.js").mustStatus(200) if !slices.Contains(r.Texts("details summary"), "Delete") { t.Error("Delete popup missing") } }) t.Run("Delete button not visible to unauthenticated user", func(t *testing.T) { r := e.anon().get("/delfile-repo/blob/main/index.js").mustStatus(200) if slices.Contains(r.Texts("details summary"), "Delete") { t.Error("Delete popup shown to anonymous visitor") } }) t.Run("deleting a file creates a commit and removes it from the tree", func(t *testing.T) { loc := admin.post("/delfile-repo/delete-file/main/index.js", url.Values{ "message": {"Remove index.js"}, }).mustRedirect("/delfile-repo/commit/") if !gitopsCommitRedirect.MatchString(loc) { t.Errorf("location = %q", loc) } if admin.get("/delfile-repo/tree/main").Contains("index.js") { t.Error("index.js still in tree") } }) t.Run("delete commit is signed", func(t *testing.T) { obj := gitopsCommitBody(t, e.repoPath("delfile-repo"), "Remove index.js") if !strings.Contains(obj, "gpgsig") { t.Errorf("commit object has no gpgsig:\n%s", obj) } }) // ─── File rename/move ──────────────────────────────────────────────── t.Run("edit form has new_path input pre-filled with current path", func(t *testing.T) { r := admin.get("/movefile-repo/edit/main/index.js").mustStatus(200) if got := r.Value("[name=new_path]"); got != "index.js" { t.Errorf("new_path = %q", got) } }) t.Run("renaming a file via edit creates a commit and old path is gone", func(t *testing.T) { loc := admin.post("/movefile-repo/edit/main/index.js", url.Values{ "content": {"console.log(\"hello\");\n"}, "new_path": {"app.js"}, "message": {"Rename index.js to app.js"}, }).mustRedirect("/movefile-repo/commit/") if !gitopsCommitRedirect.MatchString(loc) { t.Errorf("location = %q", loc) } r := admin.get("/movefile-repo/tree/main") if !r.Contains("app.js") || r.Contains("index.js") { t.Error("tree still shows index.js or misses app.js") } }) t.Run("rename commit is signed", func(t *testing.T) { obj := gitopsCommitBody(t, e.repoPath("movefile-repo"), "Rename index.js") if !strings.Contains(obj, "gpgsig") { t.Errorf("commit object has no gpgsig:\n%s", obj) } }) t.Run("renaming to invalid path shows error", func(t *testing.T) { loc := admin.post("/movefile-repo/edit/main/README.md", url.Values{ "content": {"# movefile-repo\n"}, "new_path": {"../escape.md"}, "message": {"bad"}, }).mustRedirect("/movefile-repo/edit/main/README.md") if !strings.Contains(loc, "error") { t.Errorf("location = %q", loc) } }) }