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}