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