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}