@@ -303,6 +303,118 @@ func TestRepoActionRunRendersWorkflowRunJobsAndSteps(t *testing.T) { |
| 303 | } | 303 | } |
| 304 | } | 304 | } |
| 305 | | 305 | |
| | 306 | +func TestRepoActionRunRendersCancelControlsForWritersOnly(t *testing.T) { |
| | 307 | + t.Parallel() |
| | 308 | + f := newRepoFixture(t) |
| | 309 | + now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC) |
| | 310 | + runID := f.insertWorkflowRun(t, workflowRunFixture{ |
| | 311 | + RunIndex: 12, |
| | 312 | + WorkflowFile: ".shithub/workflows/ci.yml", |
| | 313 | + WorkflowName: "CI", |
| | 314 | + HeadRef: "trunk", |
| | 315 | + Event: actionsdb.WorkflowRunEventPush, |
| | 316 | + Status: actionsdb.WorkflowRunStatusRunning, |
| | 317 | + ActorUserID: f.owner.ID, |
| | 318 | + CreatedOffset: -5 * time.Minute, |
| | 319 | + StartedOffset: -4 * time.Minute, |
| | 320 | + }, now) |
| | 321 | + f.insertWorkflowJob(t, workflowJobFixture{ |
| | 322 | + RunID: runID, |
| | 323 | + JobIndex: 0, |
| | 324 | + JobKey: "build", |
| | 325 | + JobName: "Build", |
| | 326 | + RunsOn: "ubuntu-latest", |
| | 327 | + Status: actionsdb.WorkflowJobStatusQueued, |
| | 328 | + }) |
| | 329 | + |
| | 330 | + resp := httptest.NewRecorder() |
| | 331 | + req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/12", nil) |
| | 332 | + f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req) |
| | 333 | + if resp.Code != http.StatusOK { |
| | 334 | + t.Fatalf("owner status=%d body=%s", resp.Code, resp.Body.String()) |
| | 335 | + } |
| | 336 | + body := resp.Body.String() |
| | 337 | + for _, want := range []string{ |
| | 338 | + "CANCEL_RUN=/alice/public-repo/actions/runs/12/cancel;", |
| | 339 | + "CANCEL_JOB=/alice/public-repo/actions/runs/12/jobs/0/cancel;", |
| | 340 | + } { |
| | 341 | + if !strings.Contains(body, want) { |
| | 342 | + t.Fatalf("owner body missing %q in %s", want, body) |
| | 343 | + } |
| | 344 | + } |
| | 345 | + |
| | 346 | + resp = httptest.NewRecorder() |
| | 347 | + req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/12", nil) |
| | 348 | + f.actionsMux(viewerFor(f.stranger)).ServeHTTP(resp, req) |
| | 349 | + if resp.Code != http.StatusOK { |
| | 350 | + t.Fatalf("stranger status=%d body=%s", resp.Code, resp.Body.String()) |
| | 351 | + } |
| | 352 | + if strings.Contains(resp.Body.String(), "CANCEL_") { |
| | 353 | + t.Fatalf("cancel controls leaked to non-writer: %s", resp.Body.String()) |
| | 354 | + } |
| | 355 | +} |
| | 356 | + |
| | 357 | +func TestRepoActionRunCancelCancelsQueuedRun(t *testing.T) { |
| | 358 | + t.Parallel() |
| | 359 | + f := newRepoFixture(t) |
| | 360 | + now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC) |
| | 361 | + runID := f.insertWorkflowRun(t, workflowRunFixture{ |
| | 362 | + RunIndex: 13, |
| | 363 | + WorkflowFile: ".shithub/workflows/ci.yml", |
| | 364 | + WorkflowName: "CI", |
| | 365 | + HeadRef: "trunk", |
| | 366 | + Event: actionsdb.WorkflowRunEventPush, |
| | 367 | + Status: actionsdb.WorkflowRunStatusQueued, |
| | 368 | + ActorUserID: f.owner.ID, |
| | 369 | + CreatedOffset: -5 * time.Minute, |
| | 370 | + }, now) |
| | 371 | + jobID := f.insertWorkflowJob(t, workflowJobFixture{ |
| | 372 | + RunID: runID, |
| | 373 | + JobIndex: 0, |
| | 374 | + JobKey: "build", |
| | 375 | + JobName: "Build", |
| | 376 | + RunsOn: "ubuntu-latest", |
| | 377 | + Status: actionsdb.WorkflowJobStatusQueued, |
| | 378 | + }) |
| | 379 | + stepID := f.insertWorkflowStep(t, workflowStepFixture{ |
| | 380 | + JobID: jobID, |
| | 381 | + StepIndex: 0, |
| | 382 | + RunCommand: "go test ./...", |
| | 383 | + }) |
| | 384 | + |
| | 385 | + resp := httptest.NewRecorder() |
| | 386 | + req := httptest.NewRequest(http.MethodPost, "/alice/public-repo/actions/runs/13/cancel", nil) |
| | 387 | + f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req) |
| | 388 | + if resp.Code != http.StatusSeeOther { |
| | 389 | + t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String()) |
| | 390 | + } |
| | 391 | + if loc := resp.Header().Get("Location"); loc != "/alice/public-repo/actions/runs/13" { |
| | 392 | + t.Fatalf("Location=%q", loc) |
| | 393 | + } |
| | 394 | + job, err := actionsdb.New().GetWorkflowJobByID(context.Background(), f.pool, jobID) |
| | 395 | + if err != nil { |
| | 396 | + t.Fatalf("GetWorkflowJobByID: %v", err) |
| | 397 | + } |
| | 398 | + if job.Status != actionsdb.WorkflowJobStatusCancelled || !job.CancelRequested { |
| | 399 | + t.Fatalf("job: %+v", job) |
| | 400 | + } |
| | 401 | + step, err := actionsdb.New().GetWorkflowStepByID(context.Background(), f.pool, stepID) |
| | 402 | + if err != nil { |
| | 403 | + t.Fatalf("GetWorkflowStepByID: %v", err) |
| | 404 | + } |
| | 405 | + if step.Status != actionsdb.WorkflowStepStatusCancelled { |
| | 406 | + t.Fatalf("step: %+v", step) |
| | 407 | + } |
| | 408 | + run, err := actionsdb.New().GetWorkflowRunByID(context.Background(), f.pool, runID) |
| | 409 | + if err != nil { |
| | 410 | + t.Fatalf("GetWorkflowRunByID: %v", err) |
| | 411 | + } |
| | 412 | + if run.Status != actionsdb.WorkflowRunStatusCompleted || |
| | 413 | + !run.Conclusion.Valid || run.Conclusion.CheckConclusion != actionsdb.CheckConclusionCancelled { |
| | 414 | + t.Fatalf("run: %+v", run) |
| | 415 | + } |
| | 416 | +} |
| | 417 | + |
| 306 | func TestRepoActionRunStatusRendersPollingFragment(t *testing.T) { | 418 | func TestRepoActionRunStatusRendersPollingFragment(t *testing.T) { |
| 307 | t.Parallel() | 419 | t.Parallel() |
| 308 | f := newRepoFixture(t) | 420 | f := newRepoFixture(t) |
@@ -528,6 +640,8 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler { |
| 528 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog) | 640 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog) |
| 529 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus) | 641 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus) |
| 530 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun) | 642 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun) |
| | 643 | + mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/cancel", f.handlers.repoActionRunCancel) |
| | 644 | + mux.Post("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/cancel", f.handlers.repoActionJobCancel) |
| 531 | mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch) | 645 | mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch) |
| 532 | mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions) | 646 | mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions) |
| 533 | return mux | 647 | return mux |