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