gitops_test.go
⎇
Raw
1package e2e
2
3import (
4 "net/url"
5 "regexp"
6 "slices"
7 "strings"
8 "testing"
9
10 "github.com/PuerkitoBio/goquery"
11)
12
13// gitopsCommitRedirect matches the redirect target of a write operation.
14var gitopsCommitRedirect = regexp.MustCompile(`^/[a-z-]+/commit/[0-9a-f]{40}$`)
15
16// gitopsRefNames lists the branch or tag names on a ref list page.
17func gitopsRefNames(r *response) []string { return r.Texts(".ref-item .ref-name") }
18
19// gitopsRefItem returns the list row of one branch or tag, or nil.
20func gitopsRefItem(r *response, name string) *goquery.Selection {
21 var found *goquery.Selection
22 r.Find(".ref-item").EachWithBreak(func(_ int, s *goquery.Selection) bool {
23 if strings.TrimSpace(s.Find(".ref-name").First().Text()) == name {
24 found = s
25 return false
26 }
27 return true
28 })
29 return found
30}
31
32// gitopsSummaries lists the popup toggle labels inside a row.
33func gitopsSummaries(sel *goquery.Selection) []string {
34 var out []string
35 sel.Find("summary").Each(func(_ int, s *goquery.Selection) {
36 out = append(out, strings.TrimSpace(s.Text()))
37 })
38 return out
39}
40
41// gitopsCommitBody returns the raw commit object of the newest commit whose
42// message matches grep.
43func gitopsCommitBody(t *testing.T, dir, grep string) string {
44 t.Helper()
45 hash := gitRun(t, dir, "log", "--format=%H", "--grep="+grep, "-1")
46 if hash == "" {
47 t.Fatalf("no commit matching %q", grep)
48 }
49 return gitRun(t, dir, "cat-file", "-p", hash)
50}
51
52func TestGitOps(t *testing.T) {
53 e := newEnv(t)
54 admin := e.admin()
55
56 for _, name := range []string{
57 "branch-repo", "tag-repo", "selector-repo",
58 "newfile-repo", "delfile-repo", "movefile-repo",
59 } {
60 e.createRepo(admin, name)
61 }
62
63 e.seedRepo("branch-repo", nil)
64 e.seedBranch("branch-repo", "feature-a", nil)
65
66 e.seedRepo("tag-repo", nil)
67 gitRun(t, e.repoPath("tag-repo"), "tag", "v0.1.0", "HEAD")
68 e.Srv.Git.InvalidateRefCache("tag-repo")
69
70 e.seedRepo("selector-repo", nil)
71 gitRun(t, e.repoPath("selector-repo"), "tag", "stable", "HEAD")
72 e.Srv.Git.InvalidateRefCache("selector-repo")
73
74 e.seedRepo("newfile-repo", nil)
75 e.seedRepo("delfile-repo", nil)
76 e.seedRepo("movefile-repo", nil)
77
78 // ─── Branch management ───────────────────────────────────────────────
79
80 t.Run("Branches tab appears in repo nav", func(t *testing.T) {
81 tabs := admin.get("/branch-repo").mustStatus(200).Texts("a.repo-tab")
82 if !slices.Contains(tabs, "Branches") {
83 t.Errorf("repo tabs = %v", tabs)
84 }
85 })
86
87 t.Run("branches page lists all branches", func(t *testing.T) {
88 r := admin.get("/branch-repo/branches").mustStatus(200)
89 if !r.Contains("main") || !r.Contains("feature-a") {
90 t.Error("branch list is missing main or feature-a")
91 }
92 })
93
94 t.Run("default branch has a \"default\" badge", func(t *testing.T) {
95 badges := admin.get("/branch-repo/branches").Texts(".badge")
96 if !slices.Contains(badges, "default") {
97 t.Errorf("badges = %v", badges)
98 }
99 })
100
101 t.Run("branch list shows last commit hash and subject", func(t *testing.T) {
102 sha := e.headCommit("branch-repo")[:7]
103 r := admin.get("/branch-repo/branches")
104 if !r.Contains(sha) || !r.Contains("Initial commit") {
105 t.Errorf("branch list misses %q or the subject", sha)
106 }
107 })
108
109 t.Run("branches page returns 404 for non-existent repo", func(t *testing.T) {
110 e.anon().get("/does-not-exist/branches").mustStatus(404)
111 })
112
113 t.Run("create a new branch", func(t *testing.T) {
114 r := admin.post("/branch-repo/branches/create", url.Values{
115 "name": {"new-feature"}, "source_ref": {"main"},
116 })
117 r.mustRedirect("/branch-repo/branches")
118 names := gitopsRefNames(admin.follow(r))
119 if !slices.Contains(names, "new-feature") {
120 t.Errorf("branches = %v", names)
121 }
122 })
123
124 t.Run("creating branch with invalid name shows error", func(t *testing.T) {
125 loc := admin.post("/branch-repo/branches/create", url.Values{
126 "name": {"--invalid"}, "source_ref": {"main"},
127 }).mustRedirect("/branch-repo/branches")
128 if !strings.Contains(loc, "error") {
129 t.Errorf("location = %q", loc)
130 }
131 })
132
133 t.Run("creating branch from non-existent ref shows error", func(t *testing.T) {
134 loc := admin.post("/branch-repo/branches/create", url.Values{
135 "name": {"bad-branch"}, "source_ref": {"does-not-exist"},
136 }).mustRedirect("/branch-repo/branches")
137 if !strings.Contains(loc, "error") {
138 t.Errorf("location = %q", loc)
139 }
140 })
141
142 t.Run("rename a branch", func(t *testing.T) {
143 r := admin.post("/branch-repo/branches/rename", url.Values{
144 "old_name": {"feature-a"}, "new_name": {"feature-renamed"},
145 })
146 r.mustRedirect("/branch-repo/branches")
147 names := gitopsRefNames(admin.follow(r))
148 if !slices.Contains(names, "feature-renamed") || slices.Contains(names, "feature-a") {
149 t.Errorf("branches = %v", names)
150 }
151 })
152
153 t.Run("delete a non-default branch", func(t *testing.T) {
154 r := admin.post("/branch-repo/branches/delete", url.Values{"name": {"new-feature"}})
155 r.mustRedirect("/branch-repo/branches")
156 names := gitopsRefNames(admin.follow(r))
157 if slices.Contains(names, "new-feature") {
158 t.Errorf("branches = %v", names)
159 }
160 })
161
162 t.Run("cannot delete the default branch (no Delete button on main row)", func(t *testing.T) {
163 row := gitopsRefItem(admin.get("/branch-repo/branches"), "main")
164 if row == nil {
165 t.Fatal("main row missing")
166 }
167 if labels := gitopsSummaries(row); slices.Contains(labels, "Delete") {
168 t.Errorf("main row actions = %v", labels)
169 }
170 })
171
172 t.Run("renaming default branch updates it in repo", func(t *testing.T) {
173 r := admin.post("/branch-repo/branches/rename", url.Values{
174 "old_name": {"main"}, "new_name": {"trunk"},
175 })
176 r.mustRedirect("/branch-repo/branches")
177 row := gitopsRefItem(admin.follow(r), "trunk")
178 if row == nil {
179 t.Fatal("trunk row missing")
180 }
181 if row.Find(".badge").Length() == 0 {
182 t.Error("trunk is not marked as the default branch")
183 }
184 // Rename back so later reads of this repo still see main.
185 admin.post("/branch-repo/branches/rename", url.Values{
186 "old_name": {"trunk"}, "new_name": {"main"},
187 }).mustRedirect("/branch-repo/branches")
188 })
189
190 // ─── Tag management ──────────────────────────────────────────────────
191
192 t.Run("Tags tab appears in repo nav", func(t *testing.T) {
193 tabs := admin.get("/tag-repo").mustStatus(200).Texts("a.repo-tab")
194 if !slices.Contains(tabs, "Tags") {
195 t.Errorf("repo tabs = %v", tabs)
196 }
197 })
198
199 t.Run("tags page lists existing tags", func(t *testing.T) {
200 if !admin.get("/tag-repo/tags").mustStatus(200).Contains("v0.1.0") {
201 t.Error("v0.1.0 missing")
202 }
203 })
204
205 t.Run("tag links to correct tree view", func(t *testing.T) {
206 r := admin.get("/tag-repo/tags")
207 if r.Count(`a[href="/tag-repo/tree/v0.1.0"]`) == 0 {
208 t.Error("tree link for v0.1.0 missing")
209 }
210 })
211
212 t.Run("create a new tag", func(t *testing.T) {
213 r := admin.post("/tag-repo/tags/create", url.Values{
214 "name": {"v1.0.0"}, "ref": {"main"},
215 })
216 r.mustRedirect("/tag-repo/tags")
217 names := gitopsRefNames(admin.follow(r))
218 if !slices.Contains(names, "v1.0.0") {
219 t.Errorf("tags = %v", names)
220 }
221 })
222
223 t.Run("create annotated tag with message", func(t *testing.T) {
224 r := admin.post("/tag-repo/tags/create", url.Values{
225 "name": {"v1.1.0-annotated"}, "ref": {"main"},
226 "message": {"Annotated release tag"},
227 })
228 r.mustRedirect("/tag-repo/tags")
229 names := gitopsRefNames(admin.follow(r))
230 if !slices.Contains(names, "v1.1.0-annotated") {
231 t.Errorf("tags = %v", names)
232 }
233 })
234
235 t.Run("delete a tag", func(t *testing.T) {
236 r := admin.post("/tag-repo/tags/delete", url.Values{"name": {"v1.0.0"}})
237 r.mustRedirect("/tag-repo/tags")
238 names := gitopsRefNames(admin.follow(r))
239 if slices.Contains(names, "v1.0.0") || !slices.Contains(names, "v0.1.0") {
240 t.Errorf("tags = %v", names)
241 }
242 })
243
244 t.Run("tag linked to release shows release badge and warning on delete", func(t *testing.T) {
245 admin.postMultipart("/tag-repo/releases", url.Values{
246 "name": {"Linked Release"}, "create_tag": {"on"},
247 "tag_name": {"v-linked"}, "revision": {"main"},
248 }).mustRedirect("/tag-repo/releases")
249
250 row := gitopsRefItem(admin.get("/tag-repo/tags"), "v-linked")
251 if row == nil {
252 t.Fatal("v-linked row missing")
253 }
254 if row.Find(".badge-release").Length() == 0 {
255 t.Error("release badge missing")
256 }
257 if row.Find(".confirm-warning").Length() == 0 {
258 t.Error("delete warning missing")
259 }
260 })
261
262 // ─── BranchSelector with tags ────────────────────────────────────────
263
264 t.Run("branch selector shows Tags optgroup when tags exist", func(t *testing.T) {
265 r := admin.get("/selector-repo").mustStatus(200)
266 if n := r.Count(`optgroup[label="Tags"]`); n != 1 {
267 t.Errorf("Tags optgroups = %d", n)
268 }
269 if !contains(r.Texts("option"), "stable") {
270 t.Error("stable option missing")
271 }
272 })
273
274 t.Run("branch selector shows Branches optgroup when tags exist", func(t *testing.T) {
275 if n := admin.get("/selector-repo").Count(`optgroup[label="Branches"]`); n != 1 {
276 t.Errorf("Branches optgroups = %d", n)
277 }
278 })
279
280 t.Run("branch selector on blob view includes tag optgroup", func(t *testing.T) {
281 r := admin.get("/selector-repo/blob/main/README.md").mustStatus(200)
282 if n := r.Count(`optgroup[label="Tags"]`); n != 1 {
283 t.Errorf("Tags optgroups = %d", n)
284 }
285 })
286
287 // ─── File creation ───────────────────────────────────────────────────
288
289 t.Run("New file button appears in tree toolbar for admin on a branch", func(t *testing.T) {
290 if admin.get("/newfile-repo/tree/main").mustStatus(200).
291 Count(`a[href*="/new-file/main"]`) == 0 {
292 t.Error("New file link missing")
293 }
294 })
295
296 t.Run("New file button not visible on commit SHA view", func(t *testing.T) {
297 sha := e.headCommit("newfile-repo")
298 if n := admin.get("/newfile-repo/tree/" + sha).Count(`a[href*="/new-file/"]`); n != 0 {
299 t.Errorf("New file links = %d", n)
300 }
301 })
302
303 t.Run("New file button not visible to unauthenticated user", func(t *testing.T) {
304 if n := e.anon().get("/newfile-repo/tree/main").Count(`a[href*="/new-file/main"]`); n != 0 {
305 t.Errorf("New file links = %d", n)
306 }
307 })
308
309 t.Run("new file form pre-fills dir when ?dir= query param is provided", func(t *testing.T) {
310 if got := admin.get("/newfile-repo/new-file/main?dir=src").Value("[name=path]"); got != "src/" {
311 t.Errorf("path = %q", got)
312 }
313 })
314
315 t.Run("creating a new file creates a commit and redirects to commit view", func(t *testing.T) {
316 loc := admin.post("/newfile-repo/new-file/main", url.Values{
317 "path": {"hello.txt"}, "content": {"Hello, world!\n"}, "message": {"Add hello.txt"},
318 }).mustRedirect("/newfile-repo/commit/")
319 if !gitopsCommitRedirect.MatchString(loc) {
320 t.Errorf("location = %q", loc)
321 }
322 })
323
324 t.Run("new file appears in tree after creation", func(t *testing.T) {
325 if !admin.get("/newfile-repo/tree/main").Contains("hello.txt") {
326 t.Error("hello.txt missing from tree")
327 }
328 })
329
330 t.Run("new file commit is signed", func(t *testing.T) {
331 obj := gitopsCommitBody(t, e.repoPath("newfile-repo"), "Add hello.txt")
332 if !strings.Contains(obj, "gpgsig") {
333 t.Errorf("commit object has no gpgsig:\n%s", obj)
334 }
335 })
336
337 t.Run("creating file with invalid path shows error", func(t *testing.T) {
338 loc := admin.post("/newfile-repo/new-file/main", url.Values{
339 "path": {"../escape"}, "content": {""}, "message": {"bad"},
340 }).mustRedirect("/newfile-repo/new-file/main")
341 if !strings.Contains(loc, "error") {
342 t.Errorf("location = %q", loc)
343 }
344 })
345
346 // ─── File deletion ───────────────────────────────────────────────────
347
348 t.Run("Delete button appears in file blob for admin on a branch", func(t *testing.T) {
349 r := admin.get("/delfile-repo/blob/main/index.js").mustStatus(200)
350 if !slices.Contains(r.Texts("details summary"), "Delete") {
351 t.Error("Delete popup missing")
352 }
353 })
354
355 t.Run("Delete button not visible to unauthenticated user", func(t *testing.T) {
356 r := e.anon().get("/delfile-repo/blob/main/index.js").mustStatus(200)
357 if slices.Contains(r.Texts("details summary"), "Delete") {
358 t.Error("Delete popup shown to anonymous visitor")
359 }
360 })
361
362 t.Run("deleting a file creates a commit and removes it from the tree", func(t *testing.T) {
363 loc := admin.post("/delfile-repo/delete-file/main/index.js", url.Values{
364 "message": {"Remove index.js"},
365 }).mustRedirect("/delfile-repo/commit/")
366 if !gitopsCommitRedirect.MatchString(loc) {
367 t.Errorf("location = %q", loc)
368 }
369 if admin.get("/delfile-repo/tree/main").Contains("index.js") {
370 t.Error("index.js still in tree")
371 }
372 })
373
374 t.Run("delete commit is signed", func(t *testing.T) {
375 obj := gitopsCommitBody(t, e.repoPath("delfile-repo"), "Remove index.js")
376 if !strings.Contains(obj, "gpgsig") {
377 t.Errorf("commit object has no gpgsig:\n%s", obj)
378 }
379 })
380
381 // ─── File rename/move ────────────────────────────────────────────────
382
383 t.Run("edit form has new_path input pre-filled with current path", func(t *testing.T) {
384 r := admin.get("/movefile-repo/edit/main/index.js").mustStatus(200)
385 if got := r.Value("[name=new_path]"); got != "index.js" {
386 t.Errorf("new_path = %q", got)
387 }
388 })
389
390 t.Run("renaming a file via edit creates a commit and old path is gone", func(t *testing.T) {
391 loc := admin.post("/movefile-repo/edit/main/index.js", url.Values{
392 "content": {"console.log(\"hello\");\n"}, "new_path": {"app.js"},
393 "message": {"Rename index.js to app.js"},
394 }).mustRedirect("/movefile-repo/commit/")
395 if !gitopsCommitRedirect.MatchString(loc) {
396 t.Errorf("location = %q", loc)
397 }
398 r := admin.get("/movefile-repo/tree/main")
399 if !r.Contains("app.js") || r.Contains("index.js") {
400 t.Error("tree still shows index.js or misses app.js")
401 }
402 })
403
404 t.Run("rename commit is signed", func(t *testing.T) {
405 obj := gitopsCommitBody(t, e.repoPath("movefile-repo"), "Rename index.js")
406 if !strings.Contains(obj, "gpgsig") {
407 t.Errorf("commit object has no gpgsig:\n%s", obj)
408 }
409 })
410
411 t.Run("renaming to invalid path shows error", func(t *testing.T) {
412 loc := admin.post("/movefile-repo/edit/main/README.md", url.Values{
413 "content": {"# movefile-repo\n"}, "new_path": {"../escape.md"}, "message": {"bad"},
414 }).mustRedirect("/movefile-repo/edit/main/README.md")
415 if !strings.Contains(loc, "error") {
416 t.Errorf("location = %q", loc)
417 }
418 })
419}
420