v0.13.0 dc42452

ci_branch_token_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"testing"
7
8	"connectrpc.com/connect"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	pb "bnema.dev/ermite-api/gen/ermite"
13	"bnema.dev/ermite/internal/domain"
14)
15
16const everyBranchWorkflow = `name: all
17on:
18  push:
19    branches: ['**']
20    tags: ['v*']
21jobs:
22  j:
23    runs-on: ubuntu-latest
24    steps:
25      - run: echo hi
26`
27
28// A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and
29// logs is not a write and stays open to whoever can read the repository.
30// Cancelling and re-running a run is a write on the ref of the run (it stops or
31// repeats the workflow of that ref), so it follows the prefixes: allowed on a
32// branch under one, refused on any other branch and on a tag. Runners serve
33// every repository and branch, so a limited token never manages them, even with
34// runner:register.
35func TestCIBranchLimitedToken(t *testing.T) {
36	c := newCIHarness(t, nil)
37	runner := c.registerRunner("alpha", "ubuntu-latest")
38	wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow}
39
40	c.push("main", wf, "")
41	c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 })
42	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main")
43	c.push("agent/claude/x", nil, "")
44	c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 })
45	runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch")
46	c.mustGit(c.wt, "tag", "v1")
47	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
48	c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 })
49	c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
50
51	var mainRun, agentRun, tagRun *pb.Run
52	for _, r := range c.runs() {
53		switch r.GetRef() {
54		case "refs/heads/main":
55			mainRun = r
56		case "refs/heads/agent/claude/x":
57			agentRun = r
58		case "refs/tags/v1":
59			tagRun = r
60		}
61	}
62	require.NotNil(t, mainRun)
63	require.NotNil(t, agentRun)
64	require.NotNil(t, tagRun)
65
66	all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister)
67	secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj")
68	limited := c.api(secret)
69	// The same token without the limit, to prove the refusals come from it.
70	unlimited := c.api(c.token(all, "proj"))
71	cancel := func(api *clients, r *pb.Run) error {
72		_, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()}))
73		return err
74	}
75	rerun := func(api *clients, r *pb.Run) error {
76		_, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()}))
77		return err
78	}
79
80	t.Run("reads", func(t *testing.T) {
81		list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
82		require.NoError(t, err)
83		assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes")
84		got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
85		require.NoError(t, err)
86		require.NotEmpty(t, got.Msg.GetJobs())
87		log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()}))
88		require.NoError(t, err)
89		var text string
90		for log.Receive() {
91			for _, l := range log.Msg().GetLines() {
92				text += l.GetText()
93			}
94		}
95		require.NoError(t, log.Err())
96		assert.Contains(t, text, "log of main")
97		_, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()}))
98		require.NoError(t, err)
99	})
100
101	t.Run("writes outside the prefixes are refused", func(t *testing.T) {
102		for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} {
103			assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name)
104			assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name)
105		}
106		// Still there: the refused rerun started no new attempt.
107		assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt())
108		// Without the limit the same token is allowed (rerun) or reaches the
109		// state rule of the run (cancel of a completed run).
110		assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun)))
111		require.NoError(t, rerun(unlimited, mainRun))
112	})
113
114	t.Run("writes under the prefixes are allowed", func(t *testing.T) {
115		require.NoError(t, rerun(limited, agentRun))
116		assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt())
117		require.NoError(t, cancel(limited, agentRun))
118	})
119
120	t.Run("runners are out of reach", func(t *testing.T) {
121		// Not restricted to a repository, so only the branch limit can refuse it.
122		secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"})
123		limited := c.api(secret)
124		_, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
125			Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true,
126		}))
127		assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register")
128		_, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
129		assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list")
130		_, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id}))
131		assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove")
132
133		// The same scopes without the limit manage runners (not restricted to a
134		// repository, which the runner rules already demand).
135		open := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
136		list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
137		require.NoError(t, err)
138		assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered")
139	})
140}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close