Commit 3eb692a4a5
Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success
Layout: unified · split
e2e/buildcancelweb_test.go added +124
| @@ -0,0 +1,124 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/url" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // TestBuildCancelWeb covers cancelling a build from its page (#162). The | ||
| 12 | // control is offered for anything the command accepts — queued or running — | ||
| 13 | // and hidden once a build reaches a terminal state; the command decides for | ||
| 14 | // real, so a stale or repeated post against a build that is no longer | ||
| 15 | // cancellable shows the refusal rather than a broken page. | ||
| 16 | func TestBuildCancelWeb(t *testing.T) { | ||
| 17 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 18 | aliceKey := inst.newKey(t, "alice") | ||
| 19 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 20 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 21 | |||
| 22 | // ci is an ordinary account; its runner key is self-added with | ||
| 23 | // --scope runner, which confines it to the runner protocol and | ||
| 24 | // read-only git rather than reaching for admin. | ||
| 25 | ciKey := inst.newKey(t, "ci") | ||
| 26 | inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub") | ||
| 27 | runnerKey := inst.newKey(t, "ci-runner") | ||
| 28 | pub, _ := os.ReadFile(runnerKey + ".pub") | ||
| 29 | if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 { | ||
| 30 | t.Fatalf("keys add --scope runner: %s", errOut) | ||
| 31 | } | ||
| 32 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 33 | t.Fatalf("repo create: %s", errOut) | ||
| 34 | } | ||
| 35 | work := t.TempDir() | ||
| 36 | env := inst.gitEnv(aliceKey) | ||
| 37 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 38 | dir := filepath.Join(work, "w") | ||
| 39 | os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755) | ||
| 40 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo fine\n"), 0o644) | ||
| 41 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 42 | mustGit(t, dir, env, "add", ".") | ||
| 43 | mustGit(t, dir, env, "commit", "-q", "-m", "ci") | ||
| 44 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 45 | |||
| 46 | alice := inst.login(t, aliceKey) | ||
| 47 | build1 := inst.base() + "/alice/app/builds/1" | ||
| 48 | |||
| 49 | // Build 1 is queued: the control is on the page, for someone with | ||
| 50 | // write access. | ||
| 51 | _, body := browserGet(t, alice, build1) | ||
| 52 | if !strings.Contains(body, `action="/alice/app/builds/1/cancel"`) { | ||
| 53 | t.Fatalf("no cancel control on a queued build:\n%s", body) | ||
| 54 | } | ||
| 55 | // A reader gets no control. | ||
| 56 | _, anon := browserGet(t, newBrowser(t), build1) | ||
| 57 | if strings.Contains(anon, "/cancel") { | ||
| 58 | t.Fatal("anonymous visitor sees the cancel control") | ||
| 59 | } | ||
| 60 | |||
| 61 | // Cancelling from the page lands where the CLI sees it. | ||
| 62 | if status, _ := browserPost(t, alice, build1+"/cancel", url.Values{}); status != 200 { | ||
| 63 | t.Fatalf("cancel post: %d", status) | ||
| 64 | } | ||
| 65 | if out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tcancelled") { | ||
| 66 | t.Fatalf("build not cancelled: %s", out) | ||
| 67 | } | ||
| 68 | // Cancelled, so the control is gone. | ||
| 69 | if _, body = browserGet(t, alice, build1); strings.Contains(body, "/cancel") { | ||
| 70 | t.Fatalf("cancel control still on a cancelled build:\n%s", body) | ||
| 71 | } | ||
| 72 | |||
| 73 | // Build 2: queued, then claimed by a runner without one actually | ||
| 74 | // running any steps — enough to move it to "running". | ||
| 75 | if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 { | ||
| 76 | t.Fatalf("trigger: %s", errOut) | ||
| 77 | } | ||
| 78 | if out, _, code := inst.ssh(t, runnerKey, "", "runner", "next", "alice/app"); code != 0 || strings.Contains(out, "no pending") { | ||
| 79 | t.Fatalf("runner claim: %s", out) | ||
| 80 | } | ||
| 81 | build2 := inst.base() + "/alice/app/builds/2" | ||
| 82 | _, body = browserGet(t, alice, build2) | ||
| 83 | if !strings.Contains(body, "running") { | ||
| 84 | t.Fatalf("build 2 not running:\n%s", body) | ||
| 85 | } | ||
| 86 | // The command accepts cancelling a running build too — that is the | ||
| 87 | // case #162 was filed for, a run going nowhere — so the control stays | ||
| 88 | // up, worded so cancelling does not read as instant. | ||
| 89 | if !strings.Contains(body, `action="/alice/app/builds/2/cancel"`) { | ||
| 90 | t.Fatalf("no cancel control on a running build:\n%s", body) | ||
| 91 | } | ||
| 92 | if !strings.Contains(body, "next check") { | ||
| 93 | t.Fatalf("cancel control does not warn it is not instant:\n%s", body) | ||
| 94 | } | ||
| 95 | |||
| 96 | // Cancelling the running build from the page lands where the CLI sees | ||
| 97 | // it, including the runner-facing wording that it stops at its next | ||
| 98 | // check rather than right away. | ||
| 99 | if status, _ := browserPost(t, alice, build2+"/cancel", url.Values{}); status != 200 { | ||
| 100 | t.Fatalf("cancel running build: %d", status) | ||
| 101 | } | ||
| 102 | out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app") | ||
| 103 | if !strings.Contains(out, "unit\tcancelled") { | ||
| 104 | t.Fatalf("running build not cancelled: %s", out) | ||
| 105 | } | ||
| 106 | log, _, _ := inst.ssh(t, aliceKey, "", "build", "log", "alice/app", "2") | ||
| 107 | if !strings.Contains(log, "cancelled by alice while running") { | ||
| 108 | t.Fatalf("log missing the while-running cancellation: %s", log) | ||
| 109 | } | ||
| 110 | // Cancelled, so the control is gone here too. | ||
| 111 | if _, body = browserGet(t, alice, build2); strings.Contains(body, "/cancel") { | ||
| 112 | t.Fatalf("cancel control still on a cancelled build:\n%s", body) | ||
| 113 | } | ||
| 114 | |||
| 115 | // A stale or repeated post against a build that can no longer be | ||
| 116 | // cancelled is refused, and the page says so rather than breaking. | ||
| 117 | status, body := browserPost(t, alice, build1+"/cancel", url.Values{}) | ||
| 118 | if status != 200 { | ||
| 119 | t.Fatalf("re-cancel post: %d", status) | ||
| 120 | } | ||
| 121 | if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "cancelled") { | ||
| 122 | t.Fatalf("refusal not surfaced on the page:\n%s", body) | ||
| 123 | } | ||
| 124 | } | ||
internal/httpd/buildpages_test.go +9 −2
| @@ -55,8 +55,10 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | |||
| 55 | var sb strings.Builder | 55 | var sb strings.Builder |
| 56 | err := web.Render(&sb, "build.html", struct { | 56 | err := web.Render(&sb, "build.html", struct { |
| 57 | repoPage | 57 | repoPage |
| 58 | Build control.BuildOut | 58 | Build control.BuildOut |
| 59 | Log string | 59 | Log string |
| 60 | CanWrite bool | ||
| 61 | Notice string | ||
| 60 | }{ | 62 | }{ |
| 61 | testRepoPage(), | 63 | testRepoPage(), |
| 62 | control.BuildOut{ | 64 | control.BuildOut{ |
| @@ -65,6 +67,7 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | |||
| 65 | CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z", | 67 | CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z", |
| 66 | }, | 68 | }, |
| 67 | "step 1 ok", | 69 | "step 1 ok", |
| 70 | true, "", | ||
| 68 | }) | 71 | }) |
| 69 | if err != nil { | 72 | if err != nil { |
| 70 | t.Fatalf("render: %v", err) | 73 | t.Fatalf("render: %v", err) |
| @@ -78,4 +81,8 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | |||
| 78 | t.Errorf("build.html missing %q", want) | 81 | t.Errorf("build.html missing %q", want) |
| 79 | } | 82 | } |
| 80 | } | 83 | } |
| 84 | // A finished build offers no cancel control, even to a writer. | ||
| 85 | if strings.Contains(out, "/cancel") { | ||
| 86 | t.Error("build.html offers cancel on a finished build") | ||
| 87 | } | ||
| 81 | } | 88 | } |
internal/httpd/builds.go +5 −3
| @@ -54,7 +54,9 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) { | |||
| 54 | 54 | ||
| 55 | s.render(w, "build.html", struct { | 55 | s.render(w, "build.html", struct { |
| 56 | repoPage | 56 | repoPage |
| 57 | Build control.BuildOut | 57 | Build control.BuildOut |
| 58 | Log string | 58 | Log string |
| 59 | }{p, b, log}) | 59 | CanWrite bool |
| 60 | Notice string | ||
| 61 | }{p, b, log, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) | ||
| 60 | } | 62 | } |
internal/httpd/releaseactions.go +14
| @@ -53,3 +53,17 @@ func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u st | |||
| 53 | _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job}) | 53 | _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job}) |
| 54 | s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "builds", msg) }) | 54 | s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "builds", msg) }) |
| 55 | } | 55 | } |
| 56 | |||
| 57 | // buildCancelSubmit withdraws a build. The page only offers the control | ||
| 58 | // while a build is still queued; the command decides for real, so a stale | ||
| 59 | // page posting against a build that has since finished sees the refusal | ||
| 60 | // instead of a silent no-op. | ||
| 61 | func (s *Server) buildCancelSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 62 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | ||
| 63 | n := r.PathValue("n") | ||
| 64 | back := func(w http.ResponseWriter, r *http.Request, msg string) { | ||
| 65 | s.backTo(w, r, "builds/"+n, msg) | ||
| 66 | } | ||
| 67 | _, msg, code := s.runControlCode(u, []string{"build", "cancel", repo, n}) | ||
| 68 | s.done(w, r, code, msg, back) | ||
| 69 | } | ||
internal/httpd/routes.go +2
| @@ -150,6 +150,8 @@ func (s *Server) Routes() []Route { | |||
| 150 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, | 150 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, |
| 151 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, | 151 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, |
| 152 | Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, | 152 | Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, |
| 153 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true, | ||
| 154 | Handler: s.checkOrigin(s.requireUser(s.buildCancelSubmit))}, | ||
| 153 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", | 155 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", |
| 154 | Handler: s.requireUser(s.settingsForm)}, | 156 | Handler: s.requireUser(s.settingsForm)}, |
| 155 | Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, | 157 | Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, |
internal/web/templates/build.html +7
| @@ -1,7 +1,14 @@ | |||
| 1 | {{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 1 | {{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 2 | {{define "content"}} |
| 3 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | ||
| 3 | <div class="headrow"> | 4 | <div class="headrow"> |
| 4 | <h1>Build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1> | 5 | <h1>Build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1> |
| 6 | <span class="spacer"></span> | ||
| 7 | {{if and .CanWrite (or (eq .Build.Status "pending") (eq .Build.Status "running"))}} | ||
| 8 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds/{{.Build.Number}}/cancel" class="actions"> | ||
| 9 | <button type="submit">{{if eq .Build.Status "running"}}Cancel (stops at the runner's next check){{else}}Cancel{{end}}</button> | ||
| 10 | </form> | ||
| 11 | {{end}} | ||
| 5 | </div> | 12 | </div> |
| 6 | <p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p> | 13 | <p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p> |
| 7 | {{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} | 14 | {{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} |