v0.12.0 dccd128

pushes_test.go RawHistoryFolder

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

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