v0.26.0 df1db36

ci_token_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6	"context"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15)
16
17func repoToken(id, secret string, run jobRefT, repo domain.RepoName, archive bool) domain.CIRepoToken {
18	return domain.CIRepoToken{
19		ID: id, Hash: domain.HashTokenSecret(secret), Repo: repo, RunID: run.RunID, RunAttempt: 1, JobID: run.JobID,
20		Archive: archive, ExpiresAt: created.Add(time.Hour), CreatedAt: created,
21	}
22}
23
24func TestCIRepoTokensLifecycle(t *testing.T) {
25	ctx := context.Background()
26	s, ci, n := ciRepo(t)
27	d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2))
28	require.NoError(t, err)
29	a, b := d.Jobs[0], d.Jobs[1]
30	refA := jobRefT{RunID: d.Run.ID, JobID: a.ID}
31	refB := jobRefT{RunID: d.Run.ID, JobID: b.ID}
32
33	own := repoToken("t1", "ermitegh_own", refA, n, false)
34	arch := repoToken("t2", "ermitegh_arch", refA, n, true)
35	other := repoToken("t3", "ermitegh_other", refB, n, false)
36	_, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch, other}})
37	require.NoError(t, err)
38
39	got, err := ci.FindRepoTokenByHash(ctx, own.Hash)
40	require.NoError(t, err)
41	assert.Equal(t, own.ID, got.ID)
42	assert.Equal(t, n, got.Repo)
43	assert.False(t, got.Archive)
44	assert.True(t, got.ExpiresAt.Equal(own.ExpiresAt))
45	byID, err := ci.GetRepoToken(ctx, "t2")
46	require.NoError(t, err)
47	assert.True(t, byID.Archive)
48	_, err = ci.FindRepoTokenByHash(ctx, domain.HashTokenSecret("ermitegh_nope"))
49	assert.ErrorIs(t, err, domain.ErrNotFound)
50	_, err = ci.GetRepoToken(ctx, "../token/x")
51	assert.ErrorIs(t, err, domain.ErrNotFound)
52
53	// They live apart from the stored tokens: ListTokens-style reads see none.
54	stored, err := s.Tokens().List(ctx)
55	require.NoError(t, err)
56	assert.Empty(t, stored)
57	_, err = s.Tokens().FindByHash(ctx, own.Hash)
58	assert.ErrorIs(t, err, domain.ErrNotFound)
59	_, err = s.Tokens().Get(ctx, "t1")
60	assert.ErrorIs(t, err, domain.ErrNotFound)
61
62	// A token that exists is not created twice.
63	_, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own}})
64	assert.Error(t, err)
65
66	// Revoking a job deletes its tokens, and only its tokens.
67	_, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: a.ID}}})
68	require.NoError(t, err)
69	for _, tok := range []domain.CIRepoToken{own, arch} {
70		_, err = ci.FindRepoTokenByHash(ctx, tok.Hash)
71		assert.ErrorIs(t, err, domain.ErrNotFound, tok.ID)
72		_, err = ci.GetRepoToken(ctx, tok.ID)
73		assert.ErrorIs(t, err, domain.ErrNotFound, tok.ID)
74	}
75	_, err = ci.FindRepoTokenByHash(ctx, other.Hash)
76	assert.NoError(t, err, "the other job keeps its token")
77
78	// Revoking again, or a job without tokens, changes nothing.
79	_, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: a.ID}, {RunID: 99, RunAttempt: 1, JobID: "99-0-0"}}})
80	require.NoError(t, err)
81
82	// A token can be revoked and created again in one batch (a new assignment).
83	again := repoToken("t4", "ermitegh_again", refB, n, false)
84	_, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: b.ID}}, RepoTokens: []domain.CIRepoToken{again}})
85	require.NoError(t, err)
86	_, err = ci.FindRepoTokenByHash(ctx, other.Hash)
87	assert.ErrorIs(t, err, domain.ErrNotFound)
88	_, err = ci.FindRepoTokenByHash(ctx, again.Hash)
89	assert.NoError(t, err)
90
91	// A token of a repository that no longer exists cannot be created.
92	require.NoError(t, s.Delete(ctx, n))
93	_, err = ci.FindRepoTokenByHash(ctx, again.Hash)
94	assert.ErrorIs(t, err, domain.ErrNotFound, "deleting the repository deletes the tokens of its runs")
95	_, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{repoToken("t5", "ermitegh_late", refA, n, false)}})
96	assert.ErrorIs(t, err, domain.ErrNotFound)
97}
98
99func TestCIActionResolutionsAreKeptPerAttemptAndSwept(t *testing.T) {
100	ctx := context.Background()
101	_, ci, n := ciRepo(t)
102	d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
103	require.NoError(t, err)
104	r := domain.CIActionResolution{
105		RunID: d.Run.ID, RunAttempt: 1, Key: "actions/checkout@v4", NameWithOwner: "actions/checkout",
106		ResolvedSHA: "1111111111111111111111111111111111111111", Source: domain.CIActionGitHub,
107	}
108	_, err = ci.Apply(ctx, ports.CIWrite{Resolutions: []domain.CIActionResolution{r}})
109	require.NoError(t, err)
110
111	got, err := ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v4")
112	require.NoError(t, err)
113	assert.Equal(t, r, got)
114	_, err = ci.GetActionResolution(ctx, d.Run.ID, 2, "actions/checkout@v4")
115	assert.ErrorIs(t, err, domain.ErrNotFound, "another attempt resolves again")
116	_, err = ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v5")
117	assert.ErrorIs(t, err, domain.ErrNotFound)
118
119	// With a token of the run, both go when the run is swept.
120	tok := repoToken("t1", "ermitegh_x", jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}, n, false)
121	_, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{tok}})
122	require.NoError(t, err)
123	run, _ := ci.GetRun(ctx, n, d.Run.ID)
124	run.Status, run.Conclusion, run.CompletedAt = domain.CICompleted, domain.CISuccess, created
125	job, _ := ci.GetJob(ctx, run.ID, 1, d.Jobs[0].ID)
126	job.Phase, job.Conclusion = domain.CIPhaseEnded, domain.CISuccess
127	_, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{run}, Jobs: []domain.CIJob{job}})
128	require.NoError(t, err)
129	ids, err := ci.Sweep(ctx, created.Add(time.Hour))
130	require.NoError(t, err)
131	assert.Equal(t, []int64{d.Run.ID}, ids)
132	_, err = ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v4")
133	assert.ErrorIs(t, err, domain.ErrNotFound)
134	_, err = ci.FindRepoTokenByHash(ctx, tok.Hash)
135	assert.ErrorIs(t, err, domain.ErrNotFound)
136}
137
138// jobRefT names a job of the first attempt of a run in these tests.
139type jobRefT struct {
140	RunID int64
141	JobID string
142}
143
144// GO-006: an archive token is indexed by the repository it reads, so deleting
145// that repository deletes it, though it belongs to a job of another one.
146func TestCIArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) {
147	ctx := context.Background()
148	s, ci, run := ciRepo(t)
149	target := name(t, "action")
150	require.NoError(t, s.Create(ctx, domain.Repo{Name: target, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
151	d, err := ci.CreateRun(ctx, run, "w.yml", draftOf(run, "w.yml", 1))
152	require.NoError(t, err)
153	ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}
154
155	own := repoToken("own", "ermitegh_own", ref, run, false)
156	arch := repoToken("arch", "ermitegh_arch", ref, target, true)
157	arch.Sha = "1111111111111111111111111111111111111111"
158	_, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch}})
159	require.NoError(t, err)
160	got, err := ci.FindRepoTokenByHash(ctx, arch.Hash)
161	require.NoError(t, err)
162	assert.Equal(t, arch.Sha, got.Sha, "the commit is stored")
163	assert.True(t, got.Archive)
164
165	require.NoError(t, s.Delete(ctx, target))
166	_, err = ci.FindRepoTokenByHash(ctx, arch.Hash)
167	assert.ErrorIs(t, err, domain.ErrNotFound, "gone with the repository it reads")
168	_, err = ci.GetRepoToken(ctx, "arch")
169	assert.ErrorIs(t, err, domain.ErrNotFound)
170	_, err = ci.FindRepoTokenByHash(ctx, own.Hash)
171	assert.NoError(t, err, "the token of the run's own repository is untouched")
172
173	// Revoking the job afterwards finds no stale entry to trip on.
174	_, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: ref.JobID}}})
175	require.NoError(t, err)
176	_, err = ci.FindRepoTokenByHash(ctx, own.Hash)
177	assert.ErrorIs(t, err, domain.ErrNotFound)
178}
179
180// GO-001: the pusher recorded with a run comes back with its source.
181func TestCIRunSourceKeepsThePusher(t *testing.T) {
182	ctx := context.Background()
183	_, ci, n := ciRepo(t)
184	for i, p := range []domain.CIPusher{{Kind: domain.CIPusherRoot}, {Kind: domain.CIPusherOwner}, {Kind: domain.CIPusherToken, TokenID: "abc123"}, {}} {
185		d, err := ci.CreateRun(ctx, n, "w.yml", func(id, number int64) ports.CIRunDraft {
186			dr := draftOf(n, "w.yml", 0)(id, number)
187			dr.Source.Pusher = p
188			return dr
189		})
190		require.NoError(t, err, i)
191		src, err := ci.GetRunSource(ctx, d.Run.ID)
192		require.NoError(t, err)
193		assert.Equal(t, p, src.Pusher)
194	}
195}
196
197func TestCIRepoTokenKeepsItsPermissions(t *testing.T) {
198	ctx := context.Background()
199	_, ci, n := ciRepo(t)
200	d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
201	require.NoError(t, err)
202	ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}
203	with := repoToken("p1", "ermitegh_with", ref, n, false)
204	with.Permissions = map[string]string{"contents": "write", "metadata": "read"}
205	without := repoToken("p2", "ermitegh_without", ref, n, false)
206	_, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{with, without}})
207	require.NoError(t, err)
208
209	got, err := ci.GetRepoToken(ctx, "p1")
210	require.NoError(t, err)
211	assert.Equal(t, with.Permissions, got.Permissions)
212	got, err = ci.FindRepoTokenByHash(ctx, without.Hash)
213	require.NoError(t, err)
214	assert.Empty(t, got.Permissions, "a token stored without permissions reads back as the default")
215	assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, domain.RepoTokenPrincipal(got).Scopes())
216}

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 4

Tags 45

↑↓ moveenter openesc close