v0.15.0 8a6cfec

pushes_test.go RawHistoryFolder

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}

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 2

Tags 45

↑↓ moveenter openesc close