appview_test.go
1package appview
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/cookiejar"
9 "net/http/httptest"
10 "net/url"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "strings"
16 "testing"
17 "time"
18
19 "golang.org/x/crypto/bcrypt"
20
21 "git.kilimanjaro.io/ci/internal/eventlog"
22 "git.kilimanjaro.io/ci/internal/ident"
23 "git.kilimanjaro.io/ci/internal/lex"
24 "git.kilimanjaro.io/ci/internal/runner"
25)
26
27var ciBinary string
28
29func TestMain(m *testing.M) {
30 dir, _ := os.MkdirTemp("", "ci-appview-test")
31 ciBinary = filepath.Join(dir, "ci")
32 if out, err := exec.Command("go", "build", "-o", ciBinary, "../../cmd/ci").CombinedOutput(); err != nil {
33 panic(string(out))
34 }
35 os.Symlink("ci", filepath.Join(dir, "ci-result"))
36 code := m.Run()
37 os.RemoveAll(dir)
38 os.Exit(code)
39}
40
41func sh(t *testing.T, dir string, args ...string) string {
42 t.Helper()
43 cmd := exec.Command(args[0], args[1:]...)
44 cmd.Dir = dir
45 cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
46 "GIT_COMMITTER_EMAIL=t@t", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
47 out, err := cmd.CombinedOutput()
48 if err != nil {
49 t.Fatalf("%v: %v\n%s", args, err, out)
50 }
51 return strings.TrimSpace(string(out))
52}
53
54const wf = `
55when: [{ event: [push, manual], branch: main }]
56engine: devenv
57steps: [build, deploy]
58actions:
59 build: { command: "echo building; echo prev > $CI_STATE/prev" }
60 deploy: { command: "echo deploying", gate: { prompt: "Deploy demo?" } }
61 rollback: { command: "cat $CI_STATE/prev", manual: true, style: danger, label: Roll back }
62 note: { command: "echo noted", manual: true }
63`
64
65const agentToken = "agent-token-0123456789"
66
67type env struct {
68 t *testing.T
69 r *runner.Runner
70 av *httptest.Server
71 owner *http.Client
72 sha string
73}
74
75func setup(t *testing.T) *env {
76 t.Helper()
77 t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
78 forge := t.TempDir()
79 work := filepath.Join(t.TempDir(), "demo")
80 os.MkdirAll(filepath.Join(work, ".tangled/workflows"), 0o755)
81 os.WriteFile(filepath.Join(work, ".tangled/workflows/deploy.yml"), []byte(wf), 0o644)
82 sh(t, work, "git", "init", "-q", "-b", "main")
83 sh(t, work, "git", "add", "-A")
84 sh(t, work, "git", "commit", "-qm", "init")
85 sha := sh(t, work, "git", "rev-parse", "HEAD")
86 sh(t, "", "git", "clone", "-q", "--bare", work, filepath.Join(forge, "demo.git"))
87
88 state := t.TempDir()
89 log, err := eventlog.Open(filepath.Join(state, "runner.db"))
90 if err != nil {
91 t.Fatal(err)
92 }
93 t.Cleanup(func() { log.Close() })
94 cfg := runner.Config{RunsDir: filepath.Join(state, "runs"), CacheDir: filepath.Join(state, "cache"),
95 Knot: "git.test", OwnerDid: "did:plc:me", CloneURL: filepath.Join(forge, "{repo}.git")}
96 r, err := runner.New(cfg, log, &runner.Local{Engine: ciBinary, Shell: "bash", LocksDir: filepath.Join(state, "locks")}, nil)
97 if err != nil {
98 t.Fatal(err)
99 }
100 rs := httptest.NewServer(r.Handler("runner-token"))
101 t.Cleanup(rs.Close)
102
103 hash, _ := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
104 s := &Server{Runner: &Runner{URL: rs.URL, Token: "runner-token"},
105 Auth: &Auth{PasswordHash: hash, Key: NewKey(), Agents: map[string]string{agentToken: "claude"}},
106 Knot: "git.test", OwnerDid: "did:plc:me"}
107 av := httptest.NewServer(s.Handler())
108 t.Cleanup(av.Close)
109 jar, _ := cookiejar.New(nil)
110 owner := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
111 return &env{t: t, r: r, av: av, owner: owner, sha: sha}
112}
113
114func (e *env) do(c *http.Client, method, path string, form url.Values, token string) (int, string) {
115 e.t.Helper()
116 var body io.Reader
117 if form != nil {
118 body = strings.NewReader(form.Encode())
119 }
120 req, _ := http.NewRequest(method, e.av.URL+path, body)
121 if form != nil {
122 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
123 }
124 if token != "" {
125 req.Header.Set("Authorization", "Bearer "+token)
126 }
127 resp, err := c.Do(req)
128 if err != nil {
129 e.t.Fatal(err)
130 }
131 defer resp.Body.Close()
132 b, _ := io.ReadAll(resp.Body)
133 return resp.StatusCode, string(b)
134}
135
136func (e *env) waitStatus(id, status string) {
137 e.t.Helper()
138 for range 600 {
139 if x, err := e.r.GetRun(id); err == nil && x.Status == status {
140 time.Sleep(100 * time.Millisecond) // let the follower settle
141 return
142 }
143 time.Sleep(50 * time.Millisecond)
144 }
145 x, _ := e.r.GetRun(id)
146 e.t.Fatalf("%s: %s, want %s", id, x.Status, status)
147}
148
149var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
150
151func TestOwnerApprovesAgentCannot(t *testing.T) {
152 e := setup(t)
153 agent := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
154 if err := e.r.OnRefUpdate(context.Background(), lex.GitRefUpdate{Repo: ident.RepoDID("git.test", "demo"), Ref: "refs/heads/main", NewSha: e.sha}); err != nil {
155 t.Fatal(err)
156 }
157 runs, _ := e.r.ListRuns("", 5)
158 id := runs[0].ID
159 e.waitStatus(id, "paused")
160
161 // pages need the owner's session; agents are sent to log in
162 if code, _ := e.do(agent, "GET", "/", nil, agentToken); code != http.StatusSeeOther {
163 t.Errorf("agent page: %d", code)
164 }
165 // the agent API reads and refuses gates
166 if code, body := e.do(agent, "GET", "/api/runs", nil, agentToken); code != 200 || !strings.Contains(body, id) {
167 t.Errorf("agent runs: %d %s", code, body)
168 }
169 if code, _ := e.do(agent, "GET", "/api/runs", nil, "wrong-token-xxxxxxxxx"); code != 401 {
170 t.Errorf("bad token: %d", code)
171 }
172 if code, _ := e.do(agent, "POST", "/api/runs/"+id+"/gate", url.Values{"option": {"1"}}, agentToken); code != 403 {
173 t.Errorf("agent gate: %d", code)
174 }
175 if code, _ := e.do(agent, "POST", "/runs/"+id+"/gate", url.Values{"option": {"1"}}, agentToken); code != 403 {
176 t.Errorf("agent form gate: %d", code)
177 }
178
179 // owner login
180 if code, _ := e.do(e.owner, "POST", "/login", url.Values{"password": {"nope"}, "next": {"/"}}, ""); code != 401 {
181 t.Errorf("wrong password: %d", code)
182 }
183 if code, _ := e.do(e.owner, "POST", "/login", url.Values{"password": {"pw"}, "next": {"//evil.example"}}, ""); code != http.StatusSeeOther {
184 t.Fatalf("login: %d", code)
185 }
186 code, body := e.do(e.owner, "GET", "/", nil, "")
187 if code != 200 || !strings.Contains(body, "Needs you") || !strings.Contains(body, "Deploy demo?") {
188 t.Fatalf("runs page: %d\n%s", code, body)
189 }
190 code, body = e.do(e.owner, "GET", "/runs/"+id, nil, "")
191 if code != 200 || !strings.Contains(body, "Deploy demo?") || !strings.Contains(body, "building") || !strings.Contains(body, "Roll back") {
192 t.Fatalf("run page: %d\n%s", code, body)
193 }
194 csrf := csrfRe.FindStringSubmatch(body)[1]
195
196 // without the form token: refused
197 if code, _ := e.do(e.owner, "POST", "/runs/"+id+"/gate", url.Values{"option": {"1"}}, ""); code != 403 {
198 t.Errorf("no csrf: %d", code)
199 }
200 if code, _ := e.do(e.owner, "POST", "/runs/"+id+"/gate", url.Values{"option": {"1"}, "csrf": {csrf}}, ""); code != http.StatusSeeOther {
201 t.Fatalf("approve: %d", code)
202 }
203 e.waitStatus(id, "success")
204 st, _ := e.r.GetRun(id)
205 b, _ := os.ReadFile(filepath.Join(st.Dir, "events.ndjson"))
206 if !strings.Contains(string(b), `"actor":"did:plc:me"`) {
207 t.Error("approval not recorded with the owner's DID")
208 }
209
210 // agents may run non-danger actions, not danger ones
211 if code, _ := e.do(agent, "POST", "/api/runs/"+id+"/actions/rollback", url.Values{}, agentToken); code != 403 {
212 t.Errorf("agent danger action: %d", code)
213 }
214 code, body = e.do(agent, "POST", "/api/runs/"+id+"/actions/note", url.Values{}, agentToken)
215 if code != 200 {
216 t.Fatalf("agent note: %d %s", code, body)
217 }
218 var child map[string]string
219 json.Unmarshal([]byte(body), &child)
220 e.waitStatus(child["run"], "success")
221
222 // the live stream of a finished run ends at once
223 code, body = e.do(e.owner, "GET", "/runs/"+id+"/live?after=9999", nil, "")
224 if code != 200 || !strings.Contains(body, "event: end") {
225 t.Errorf("live: %d %q", code, body)
226 }
227 // plain-text log for agents
228 if code, body := e.do(agent, "GET", "/api/runs/"+id+"/log.txt", nil, agentToken); code != 200 || !strings.Contains(body, "== gate approved") {
229 t.Errorf("log.txt: %d\n%s", code, body)
230 }
231
232 // agents can trigger
233 if code, _ := e.do(agent, "POST", "/api/trigger", nil, agentToken); code != 400 {
234 t.Errorf("trigger without body: %d", code)
235 }
236 req, _ := http.NewRequest("POST", e.av.URL+"/api/trigger", strings.NewReader(`{"repo":"demo","workflow":"deploy","ref":"main"}`))
237 req.Header.Set("Authorization", "Bearer "+agentToken)
238 resp, err := agent.Do(req)
239 if err != nil {
240 t.Fatal(err)
241 }
242 rb, _ := io.ReadAll(resp.Body)
243 resp.Body.Close()
244 if resp.StatusCode != 200 || !strings.Contains(string(rb), "at://did:web:git.test/sh.tangled.pipeline/") {
245 t.Errorf("trigger: %d %s", resp.StatusCode, rb)
246 }
247}
248
249func TestSafeNext(t *testing.T) {
250 for in, want := range map[string]string{"/runs/x": "/runs/x", "//evil": "/", "https://evil": "/", "/\\evil": "/", "": "/"} {
251 if got := safeNext(in); got != want {
252 t.Errorf("safeNext(%q) = %q", in, got)
253 }
254 }
255}
256
257// A local `ci run` waits for a concurrency group the runner holds, then
258// reports itself (its action is audited) so it shows on the runner.
259func TestLocalRunUsesRunnerLocksAndReports(t *testing.T) {
260 e := setup(t)
261 repo := filepath.Join(t.TempDir(), "local")
262 os.MkdirAll(filepath.Join(repo, ".tangled/workflows"), 0o755)
263 os.WriteFile(filepath.Join(repo, ".tangled/workflows/deploy.yml"), []byte(`
264engine: devenv
265steps: [deploy]
266actions:
267 deploy: { command: "echo deployed-locally", concurrency: { group: fleet }, audited: true }
268`), 0o644)
269 sh(t, repo, "git", "init", "-q", "-b", "main")
270 sh(t, repo, "git", "add", "-A")
271 sh(t, repo, "git", "commit", "-qm", "init")
272
273 if ok, _, _ := e.r.Lock(context.Background(), "fleet", "ci-deploy-on-runner"); !ok {
274 t.Fatal("runner lock")
275 }
276 runs := t.TempDir()
277 cmd := exec.Command(ciBinary, "run", "--shell", "bash", "-q", "deploy")
278 cmd.Dir = repo
279 cmd.Env = append(os.Environ(), "CI_RUNS_DIR="+runs, "CI_APPVIEW_URL="+e.av.URL, "CI_TOKEN="+agentToken)
280 var out strings.Builder
281 cmd.Stdout, cmd.Stderr = &out, &out
282 if err := cmd.Start(); err != nil {
283 t.Fatal(err)
284 }
285 time.Sleep(1500 * time.Millisecond)
286 ents, _ := os.ReadDir(runs)
287 var id string
288 for _, en := range ents {
289 if en.IsDir() && en.Name() != ".locks" {
290 id = en.Name()
291 }
292 }
293 b, _ := os.ReadFile(filepath.Join(runs, id, "events.ndjson"))
294 if !strings.Contains(string(b), "waiting for concurrency group fleet (held by run ci-deploy-on-runner)") || strings.Contains(string(b), "deployed-locally") {
295 t.Fatalf("local run did not wait for the runner's lock:\n%s", b)
296 }
297 e.r.Unlock("fleet", "ci-deploy-on-runner")
298 if err := cmd.Wait(); err != nil {
299 t.Fatalf("local run: %v\n%s", err, out.String())
300 }
301 if !strings.Contains(out.String(), "reported run "+id) {
302 t.Errorf("not reported:\n%s", out.String())
303 }
304 x, err := e.r.GetRun(id)
305 if err != nil || x.Status != "success" {
306 t.Fatalf("runner has %+v %v", x, err)
307 }
308 // and the lease was released: the runner can take the group again
309 if ok, _, _ := e.r.Lock(context.Background(), "fleet", "next"); !ok {
310 t.Error("lease not released")
311 }
312}