1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "net/url"
7 "strings"
8 "testing"
9 "time"
10
11 "connectrpc.com/connect"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14 "google.golang.org/protobuf/types/known/fieldmaskpb"
15
16 pb "bnema.dev/ermite-api/gen/ermite"
17 "bnema.dev/ermite/internal/domain"
18)
19
20// AC5, AC6: the pages of CI: the list of runs, a run, a job with its log (page,
21// raw and stream), the owner's buttons, and the checks on the commit, the
22// branch list, the commit list and the merge request.
23func TestWebActionsPages(t *testing.T) {
24 ci := newCIHarness(t, nil)
25 c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
26 b := browser{ci.harness}
27 runner := ci.registerRunner("alpha", "ubuntu-latest")
28 _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
29 Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
30 }))
31 require.NoError(t, err)
32 ci.push("main", map[string]string{"README": "x\n"}, "")
33 sha := ci.push("feat", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "add ci")
34 ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
35
36 // Queued: the list shows it, with a word for its state.
37 list := b.get("/proj/actions", nil)
38 require.Equal(t, 200, list.status, list.body)
39 assert.Contains(t, list.body, "queued")
40 assert.Contains(t, list.body, `href="/proj/actions/runs/1"`)
41 assert.Contains(t, list.body, `aria-label="queued"`)
42 assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&view=actions", "the page follows the repository stream")
43 assert.Contains(t, list.body, ">Actions<", "the tab")
44
45 // The MR page says "waiting for a runner" only when no runner matches.
46 mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
47 require.NoError(t, err)
48 mrPage := b.get("/proj/merge-requests/1", nil)
49 require.Equal(t, 200, mrPage.status)
50 assert.Contains(t, mrPage.body, `id="mr-checks"`)
51 assert.Contains(t, mrPage.body, "build")
52 assert.Contains(t, mrPage.body, "a failing or unfinished check blocks the merge", "the rule shown is the one that applies")
53 assert.NotContains(t, mrPage.body, "waiting for a runner", "a runner matches")
54 _ = mr
55
56 job := runner.await()
57 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "##[group]Run echo hello", "hello <b>bold</b>", "##[endgroup]")
58 next := runner.await()
59 runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "##[error]Process completed with exit code 1.")
60 ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
61
62 run := b.get("/proj/actions/runs/1", nil)
63 require.Equal(t, 200, run.status, run.body)
64 assert.Contains(t, run.body, "build")
65 assert.Contains(t, run.body, "test")
66 assert.Contains(t, run.body, "failure")
67 assert.Contains(t, run.body, "on alpha (vm)", "the runner and its isolation are named")
68 assert.NotContains(t, run.body, `action="/proj/actions/runs/1/rerun"`, "no button for a visitor")
69
70 jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
71 require.Equal(t, 200, jobPage.status, jobPage.body)
72 assert.Contains(t, jobPage.body, "Run echo hello")
73 assert.Contains(t, jobPage.body, "hello <b>bold</b>", "log text is escaped, never markup")
74 assert.NotContains(t, jobPage.body, "<b>bold</b>")
75 assert.NotContains(t, jobPage.body, "##[group]", "markers are shown as structure")
76 assert.Contains(t, jobPage.body, `class="lg group"`)
77 failed := b.get("/proj/actions/runs/1/job/"+next.GetJobId(), nil)
78 assert.Contains(t, failed.body, `class="lg error"`)
79
80 raw := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/raw", nil)
81 require.Equal(t, 200, raw.status)
82 assert.Equal(t, "text/plain; charset=utf-8", raw.hdr.Get("Content-Type"))
83 assert.Equal(t, "nosniff", raw.hdr.Get("X-Content-Type-Options"))
84 assert.Contains(t, raw.hdr.Get("Content-Security-Policy"), "sandbox")
85 assert.Contains(t, raw.body, "hello <b>bold</b>", "raw is raw text")
86 assert.Regexp(t, `(?m)^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ##\[group\]Run echo hello$`, raw.body)
87
88 // The stream of a finished job sends its lines then ends.
89 stream := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/stream?from=1", nil)
90 require.Equal(t, 200, stream.status)
91 assert.Equal(t, "text/event-stream", stream.hdr.Get("Content-Type"))
92 assert.Contains(t, stream.body, "event: ci-log")
93 assert.Contains(t, stream.body, "event: ci-end")
94 assert.NotContains(t, stream.body, "Run echo hello", "from=1 skips the first line")
95
96 // Checks next to the commit, the commits list and the branch list.
97 commit := b.get("/proj/commit/"+sha, nil)
98 require.Equal(t, 200, commit.status)
99 assert.Contains(t, commit.body, "checks failed")
100 assert.Contains(t, b.get("/proj/commits/feat", nil).body, "checks failed")
101 assert.Contains(t, b.get("/proj/refs", nil).body, "checks failed")
102
103 // Private repository: nothing to anonymous.
104 _, err = ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
105 Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PRIVATE, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
106 }))
107 require.NoError(t, err)
108 for _, p := range []string{"/proj/actions", "/proj/actions/runs/1", "/proj/actions/runs/1/job/" + job.GetJobId(), "/proj/actions/runs/1/job/" + job.GetJobId() + "/raw", "/proj/actions/runs/1/job/" + job.GetJobId() + "/stream"} {
109 assert.Equal(t, 404, b.get(p, nil).status, p)
110 }
111
112 // The owner sees the buttons and re-runs the failed job.
113 token := c.signIn(t)
114 owner := b.get("/proj/actions/runs/1", as(token))
115 require.Equal(t, 200, owner.status)
116 assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun"`)
117 assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun-failed"`)
118 assert.NotContains(t, owner.body, `action="/proj/actions/runs/1/cancel"`)
119 // A POST needs the owner's session and the origin.
120 assert.Equal(t, 403, b.do("POST", "/proj/actions/runs/1/rerun-failed", map[string]string{"Origin": publicOrigin}, "").status, "anonymous")
121 res := b.form("/proj/actions/runs/1/rerun-failed", token, url.Values{})
122 require.Equal(t, 303, res.status, res.body)
123 ci.waitFor("attempt 2 is queued", func() bool { return ci.runs()[0].GetAttempt() == 2 })
124 again := b.get("/proj/actions/runs/1?attempt=1", as(token))
125 assert.Contains(t, again.body, "attempt 1")
126 assert.NotContains(t, again.body, `action="/proj/actions/runs/1/rerun"`, "an earlier attempt has no button")
127
128 cancel := b.form("/proj/actions/runs/1/cancel", token, url.Values{})
129 require.Equal(t, 303, cancel.status, cancel.body)
130 ci.waitFor("cancelled", func() bool {
131 return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED && ci.runs()[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_CANCELLED
132 })
133 assert.Equal(t, 409, b.form("/proj/actions/runs/1/cancel", token, url.Values{}).status, "a completed run cannot be cancelled")
134}
135
136// The live updates of the pages: the run page gets the body of the run when a
137// job changes, and the job page its state, never a line of the log.
138func TestWebActionsLiveFragments(t *testing.T) {
139 ci := newCIHarness(t, nil)
140 c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
141 runner := ci.registerRunner("alpha", "ubuntu-latest")
142 _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
143 Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
144 }))
145 require.NoError(t, err)
146 ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
147 ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
148 token := c.signIn(t)
149
150 run := ci.sse("topic=repo:proj&view=run&n=1", "")
151 list := ci.sse("topic=repo:proj&view=actions", "")
152 job := runner.await()
153 jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
154 _ = token
155
156 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log")
157 deadline := time.After(10 * time.Second)
158 var sawRun, sawList, sawJob bool
159 for !sawRun || !sawList || !sawJob {
160 select {
161 case ev := <-run.events:
162 sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`))
163 assert.NotContains(t, ev.data, "a secret value in the log")
164 case ev := <-list.events:
165 sawList = sawList || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-1"`))
166 case ev := <-jobStream.events:
167 sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`))
168 assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log")
169 case <-deadline:
170 t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob)
171 }
172 }
173 _ = domain.CISuccess
174}