detached 98d36bf

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	"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}

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 4

Tags 45

↑↓ moveenter openesc close