gitops_test.go
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | var gitopsCommitRedirect = regexp.MustCompile(`^/[a-z-]+/commit/[0-9a-f]{40}([0-9a-f]{24})?$`) |
| 15 | |
| 16 | // gitopsRefNames lists the branch or tag names on a ref list page. |
| 17 | func 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. |
| 20 | func 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. |
| 33 | func 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. |
| 43 | func 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 | |
| 52 | func 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 |