1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17 "bnema.dev/ermite/internal/ports/mocks"
18 "bnema.dev/ermite/internal/usecase"
19)
20
21type pushFixture struct {
22 meta *mocks.RepoMetaStore
23 git *mocks.GitRepos
24 bus *mocks.EventBus
25 clock *mocks.Clock
26 tokens *mocks.TokenStore
27 cirepo *mocks.CIRepoTokenStore
28 sched *mocks.MaintenanceScheduler
29 pushes *usecase.Pushes
30}
31
32func newPushFixture(t *testing.T) *pushFixture {
33 t.Helper()
34 f := &pushFixture{
35 meta: mocks.NewRepoMetaStore(t),
36 git: mocks.NewGitRepos(t),
37 bus: mocks.NewEventBus(t),
38 clock: mocks.NewClock(t),
39 tokens: mocks.NewTokenStore(t),
40 cirepo: mocks.NewCIRepoTokenStore(t),
41 sched: mocks.NewMaintenanceScheduler(t),
42 }
43 f.clock.EXPECT().Now().Return(now).Maybe()
44 repos := usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
45 auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock).WithRepoTokens(f.cirepo)
46 f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
47 return f
48}
49
50const (
51 oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
52 oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
53)
54
55func TestPushesOpen(t *testing.T) {
56 t.Parallel()
57 ctx := context.Background()
58 n := name(t, "tools")
59 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
60 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic}
61
62 tests := []struct {
63 name string
64 p domain.Principal
65 repo *domain.Repo // nil: missing
66 create bool
67 want error
68 }{
69 {"anonymous", domain.Anonymous(), &pub, false, domain.ErrUnauthenticated},
70 {"root, existing", domain.Root(), &priv, false, nil},
71 {"writer, existing", tokenPrincipal(domain.ScopeRepoWrite), &priv, false, nil},
72 {"writer limited to the repo", tokenPrincipal(domain.ScopeRepoWrite, "tools"), &priv, false, nil},
73 {"writer limited to another repo, private", tokenPrincipal(domain.ScopeRepoWrite, "other"), &priv, false, domain.ErrNotFound},
74 {"writer limited to another repo, public", tokenPrincipal(domain.ScopeRepoWrite, "other"), &pub, false, domain.ErrForbidden},
75 {"reader of a private repo", tokenPrincipal(domain.ScopeRepoRead), &priv, false, domain.ErrForbidden},
76 {"reader of a public repo", tokenPrincipal(domain.ScopeRepoRead), &pub, false, domain.ErrForbidden},
77 {"token with no relevant scope, private", tokenPrincipal(domain.ScopeMRWrite), &priv, false, domain.ErrNotFound},
78 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
79 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
80 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
81 {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil},
82 {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound},
83 {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound},
84 }
85 for _, tt := range tests {
86 t.Run(tt.name, func(t *testing.T) {
87 t.Parallel()
88 f := newPushFixture(t)
89 if !tt.p.IsAnonymous() {
90 if tt.repo != nil {
91 f.meta.EXPECT().Get(ctx, n).Return(*tt.repo, nil)
92 } else {
93 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
94 }
95 }
96 // The bus mock expects no Publish: nothing is created here.
97 err := f.pushes.Open(ctx, tt.p, n, tt.create)
98 if tt.want != nil {
99 assert.ErrorIs(t, err, tt.want)
100 return
101 }
102 require.NoError(t, err)
103 })
104 }
105
106 t.Run("creates a private repository and publishes RepoCreated", func(t *testing.T) {
107 t.Parallel()
108 f := newPushFixture(t)
109 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
110 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
111 want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
112 f.meta.EXPECT().Create(ctx, want).Return(nil)
113 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
114 f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()
115
116 require.NoError(t, f.pushes.Open(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, true))
117 })
118
119 t.Run("a concurrent first push wins: this one pushes to its repository", func(t *testing.T) {
120 t.Parallel()
121 f := newPushFixture(t)
122 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound).Once()
123 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
124 f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
125 f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
126 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
127
128 // The bus mock expects no Publish: the repository was not created here.
129 require.NoError(t, f.pushes.Open(ctx, domain.Root(), n, true))
130 })
131
132 t.Run("store failure", func(t *testing.T) {
133 t.Parallel()
134 f := newPushFixture(t)
135 boom := errors.New("boom")
136 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
137 err := f.pushes.Open(ctx, domain.Root(), n, true)
138 assert.ErrorIs(t, err, boom)
139 })
140
141 t.Run("create failure is returned and nothing is published", func(t *testing.T) {
142 t.Parallel()
143 f := newPushFixture(t)
144 boom := errors.New("boom")
145 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
146 f.git.EXPECT().Stage(ctx).Return(ports.StagedID(""), boom)
147 err := f.pushes.Open(ctx, domain.Root(), n, true)
148 assert.ErrorIs(t, err, boom)
149 })
150}
151
152func TestPushesCheck(t *testing.T) {
153 t.Parallel()
154 ctx := context.Background()
155 n := name(t, "tools")
156 ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
157 writer := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "tools")})
158 reader := domain.TokenPrincipal(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)})
159
160 t.Run("root is always valid and needs no lookup", func(t *testing.T) {
161 t.Parallel()
162 f := newPushFixture(t) // the token store mock expects no call
163 require.NoError(t, f.check(ctx, domain.Root(), n, ups))
164 })
165
166 t.Run("a writer whose token still exists", func(t *testing.T) {
167 t.Parallel()
168 f := newPushFixture(t)
169 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
170 require.NoError(t, f.check(ctx, writer, n, ups))
171 })
172
173 t.Run("a token revoked during the push is rejected", func(t *testing.T) {
174 t.Parallel()
175 f := newPushFixture(t)
176 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, domain.ErrNotFound)
177 assert.ErrorIs(t, f.check(ctx, writer, n, ups), domain.ErrRejected)
178 })
179
180 t.Run("a token that expired during the push is rejected", func(t *testing.T) {
181 t.Parallel()
182 f := newPushFixture(t)
183 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: now.Add(-time.Second)}, nil)
184 assert.ErrorIs(t, f.check(ctx, writer, n, ups), domain.ErrRejected)
185 })
186
187 t.Run("a store failure fails closed", func(t *testing.T) {
188 t.Parallel()
189 f := newPushFixture(t)
190 boom := errors.New("boom")
191 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, boom)
192 err := f.check(ctx, writer, n, ups)
193 require.ErrorIs(t, err, boom)
194 assert.NotErrorIs(t, err, domain.ErrRejected, "an internal failure, not a refusal")
195 })
196
197 t.Run("a token without write access is rejected", func(t *testing.T) {
198 t.Parallel()
199 f := newPushFixture(t)
200 f.tokens.EXPECT().Get(ctx, "r1").Return(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)}, nil)
201 assert.ErrorIs(t, f.check(ctx, reader, n, ups), domain.ErrRejected)
202 })
203
204 t.Run("anonymous is rejected", func(t *testing.T) {
205 t.Parallel()
206 f := newPushFixture(t)
207 assert.ErrorIs(t, f.check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
208 })
209}
210
211// AC5: a token limited to branch prefixes pushes only under them.
212func TestPushesCheckBranchPrefixes(t *testing.T) {
213 t.Parallel()
214 ctx := context.Background()
215 n := name(t, "tools")
216 limited := domain.TokenPrincipal(domain.Token{
217 ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"},
218 })
219 up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} }
220
221 tests := []struct {
222 name string
223 updates []domain.RefUpdate
224 refused string // the ref named by the error; "" = accepted
225 }{
226 {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""},
227 {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""},
228 {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""},
229 {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""},
230 {"several under the prefixes", []domain.RefUpdate{
231 up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"),
232 }, ""},
233 {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"},
234 {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"},
235 {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"},
236 {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"},
237 {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"},
238 {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"},
239 {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"},
240 {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"},
241 {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"},
242 {"mixed push: one allowed, one forbidden", []domain.RefUpdate{
243 up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"),
244 }, "refs/heads/main"},
245 {"mixed push, forbidden first", []domain.RefUpdate{
246 up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"),
247 }, "refs/heads/main"},
248 }
249 for _, tt := range tests {
250 t.Run(tt.name, func(t *testing.T) {
251 t.Parallel()
252 f := newPushFixture(t)
253 f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
254 err := f.check(ctx, limited, n, tt.updates)
255 if tt.refused == "" {
256 require.NoError(t, err)
257 return
258 }
259 require.ErrorIs(t, err, domain.ErrRejected)
260 assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)")
261 })
262 }
263
264 t.Run("a token without limit pushes anywhere", func(t *testing.T) {
265 t.Parallel()
266 f := newPushFixture(t)
267 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
268 free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
269 require.NoError(t, f.check(ctx, free, n, []domain.RefUpdate{
270 up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
271 }))
272 })
273
274 t.Run("root pushes anywhere", func(t *testing.T) {
275 t.Parallel()
276 f := newPushFixture(t)
277 require.NoError(t, f.check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
278 })
279
280 t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
281 t.Parallel()
282 f := newPushFixture(t)
283 f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
284 p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
285 err := f.check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
286 require.ErrorIs(t, err, domain.ErrRejected)
287 assert.Contains(t, err.Error(), "no write access")
288 })
289}
290
291func TestPushesRecord(t *testing.T) {
292 t.Parallel()
293 ctx := context.Background()
294 n := name(t, "tools")
295 p := tokenPrincipal(domain.ScopeRepoWrite)
296 ups := []domain.RefUpdate{
297 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/dev"},
298 {Old: domain.ZeroOID, New: oidB, Ref: "refs/heads/main"},
299 {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"},
300 }
301
302 t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {
303 t.Parallel()
304 f := newPushFixture(t)
305 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
306 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
307 f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
308 f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
309 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
310 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
311
312 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
313 })
314
315 t.Run("tells the reader that the repository changed", func(t *testing.T) {
316 t.Parallel()
317 f := newPushFixture(t)
318 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
319 fresh := mocks.NewRepoFreshener(t)
320 fresh.EXPECT().Refresh(ctx, n).Return().Once()
321 f.pushes.WithFreshener(fresh)
322 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
323 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
324 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
325 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
326 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
327 })
328
329 t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
330 t.Parallel()
331 f := newPushFixture(t)
332 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
333 repo := domain.Repo{Name: n}
334 f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
335 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
336 later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
337 f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
338 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
339 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
340 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
341 })
342
343 t.Run("a deleted repository is not an error", func(t *testing.T) {
344 t.Parallel()
345 f := newPushFixture(t)
346 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
347 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
348 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
349 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
350 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
351 })
352
353 t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) {
354 t.Parallel()
355 f := newPushFixture(t)
356 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
357 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
358 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
359 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
360 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
361 })
362
363 t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) {
364 t.Parallel()
365 f := newPushFixture(t)
366 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
367 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
368 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
369 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
370 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
371 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
372 })
373
374 t.Run("every step runs when one fails", func(t *testing.T) {
375 t.Parallel()
376 f := newPushFixture(t)
377 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
378 boom := errors.New("boom")
379 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
380 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
381 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
382 err := f.pushes.Record(ctx, p, n, ups)
383 assert.ErrorIs(t, err, boom)
384 })
385}
386
387func TestPushesDefaultBranchRule(t *testing.T) {
388 t.Parallel()
389 ctx := context.Background()
390 n := name(t, "tools")
391 p := domain.Root()
392 up := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: oidA, Ref: ref} }
393 del := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: oidA, New: domain.ZeroOID, Ref: ref} }
394
395 tests := []struct {
396 name string
397 head string
398 exists bool
399 updates []domain.RefUpdate
400 want string // "" = HEAD untouched
401 }{
402 {"HEAD already valid", "main", true, []domain.RefUpdate{up("refs/heads/dev")}, ""},
403 {"main preferred", "trunk", false, []domain.RefUpdate{up("refs/heads/dev"), up("refs/heads/main")}, "main"},
404 {"first pushed branch", "main", false, []domain.RefUpdate{up("refs/tags/v1"), up("refs/heads/dev"), up("refs/heads/feature")}, "dev"},
405 {"HEAD names another missing branch", "trunk", false, []domain.RefUpdate{up("refs/heads/dev")}, "dev"},
406 {"only tags pushed", "main", false, []domain.RefUpdate{up("refs/tags/v1")}, ""},
407 {"deleted branches never become HEAD", "main", false, []domain.RefUpdate{del("refs/heads/old"), up("refs/heads/dev")}, "dev"},
408 {"only deletions", "main", false, []domain.RefUpdate{del("refs/heads/old")}, ""},
409 {"HEAD already points to the only candidate", "dev", false, []domain.RefUpdate{up("refs/heads/dev")}, ""},
410 }
411 for _, tt := range tests {
412 t.Run(tt.name, func(t *testing.T) {
413 t.Parallel()
414 f := newPushFixture(t)
415 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
416 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
417 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
418 if tt.want != "" {
419 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
420 }
421 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
422 require.NoError(t, f.pushes.Record(ctx, p, n, tt.updates))
423 })
424 }
425
426 t.Run("set failure is reported", func(t *testing.T) {
427 t.Parallel()
428 f := newPushFixture(t)
429 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
430 boom := errors.New("boom")
431 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
432 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
433 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
434 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
435 assert.ErrorIs(t, f.pushes.Record(ctx, p, n, []domain.RefUpdate{up("refs/heads/dev")}), boom)
436 })
437}
438
439// mockAny matches any argument of a mocked call.
440var mockAny = mock.Anything
441
442// check is Pushes.Check for a push with nothing in quarantine.
443func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.RepoName, ups []domain.RefUpdate) error {
444 return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{})
445}
446
447type fakeGuard struct {
448 path string
449 err error
450 got []domain.RefUpdate
451 objs ports.PushObjects
452}
453
454func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) {
455 g.got, g.objs = ups, o
456 return g.path, g.err
457}
458
459// A push by the token of a job is judged on the workflows it changes, and by it
460// alone: every other principal is not asked.
461func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
462 t.Parallel()
463 ctx := context.Background()
464 n := name(t, "tools")
465 ups := []domain.RefUpdate{{Old: oidA, New: oidB, Ref: "refs/heads/main"}, {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"}}
466 objs := ports.PushObjects{Dir: "/d/repos/tools.git/objects/incoming-1"}
467 exp := now.Add(time.Hour)
468 job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}})
469 readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp})
470
471 build := func(t *testing.T, g *fakeGuard) *pushFixture {
472 f := newPushFixture(t)
473 if g != nil {
474 f.pushes.WithWorkflowGuard(g)
475 }
476 f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe()
477 return f
478 }
479
480 t.Run("no workflow touched", func(t *testing.T) {
481 t.Parallel()
482 g := &fakeGuard{}
483 f := build(t, g)
484 require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs))
485 assert.Equal(t, ups, g.got, "every ref is judged")
486 assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine")
487 })
488 t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) {
489 t.Parallel()
490 f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"})
491 err := f.pushes.Check(ctx, job, n, ups, objs)
492 require.ErrorIs(t, err, domain.ErrRejected)
493 assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission")
494 })
495 t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) {
496 t.Parallel()
497 f := build(t, &fakeGuard{err: errors.New("git broke")})
498 err := f.pushes.Check(ctx, job, n, ups, objs)
499 require.Error(t, err)
500 assert.NotErrorIs(t, err, domain.ErrRejected)
501 })
502 t.Run("without a guard a job token cannot be judged: refused", func(t *testing.T) {
503 t.Parallel()
504 f := build(t, nil)
505 assert.ErrorIs(t, f.pushes.Check(ctx, job, n, ups, objs), domain.ErrRejected)
506 })
507 t.Run("a job token without contents: write cannot push at all", func(t *testing.T) {
508 t.Parallel()
509 f := build(t, &fakeGuard{})
510 assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected)
511 })
512 t.Run("the owner is not asked", func(t *testing.T) {
513 t.Parallel()
514 g := &fakeGuard{path: ".github/workflows/ci.yml"}
515 f := build(t, g)
516 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs))
517 assert.Nil(t, g.got)
518 })
519 t.Run("a job token never creates a repository on push", func(t *testing.T) {
520 t.Parallel()
521 f := build(t, &fakeGuard{})
522 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
523 assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound)
524 })
525}