v0.2.0 41cf2ec

pushes_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6	"context"
7	"errors"
8	"sync"
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/app"
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"bnema.dev/ermite/internal/ports/mocks"
20)
21
22type scheduler struct {
23	mu    sync.Mutex
24	names []domain.RepoName
25}
26
27func (s *scheduler) Schedule(_ context.Context, n domain.RepoName) {
28	s.mu.Lock()
29	defer s.mu.Unlock()
30	s.names = append(s.names, n)
31}
32
33type pushFixture struct {
34	meta   *mocks.RepoMetaStore
35	git    *mocks.GitRepos
36	bus    *mocks.EventBus
37	clock  *mocks.Clock
38	tokens *mocks.TokenStore
39	sched  *scheduler
40	pushes *app.Pushes
41}
42
43func newPushFixture(t *testing.T) *pushFixture {
44	t.Helper()
45	f := &pushFixture{
46		meta:   mocks.NewRepoMetaStore(t),
47		git:    mocks.NewGitRepos(t),
48		bus:    mocks.NewEventBus(t),
49		clock:  mocks.NewClock(t),
50		tokens: mocks.NewTokenStore(t),
51		sched:  &scheduler{},
52	}
53	f.clock.EXPECT().Now().Return(now).Maybe()
54	repos := app.NewRepos(f.meta, f.git, f.bus, f.clock)
55	auth := app.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)
56	f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
57	return f
58}
59
60const (
61	oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
62	oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
63)
64
65func TestPushesOpen(t *testing.T) {
66	ctx := context.Background()
67	n := name(t, "tools")
68	priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
69	pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic}
70
71	tests := []struct {
72		name   string
73		p      domain.Principal
74		repo   *domain.Repo // nil: missing
75		create bool
76		want   error
77	}{
78		{"anonymous", domain.Anonymous(), &pub, false, domain.ErrUnauthenticated},
79		{"root, existing", domain.Root(), &priv, false, nil},
80		{"writer, existing", tokenPrincipal(domain.ScopeRepoWrite), &priv, false, nil},
81		{"writer limited to the repo", tokenPrincipal(domain.ScopeRepoWrite, "tools"), &priv, false, nil},
82		{"writer limited to another repo, private", tokenPrincipal(domain.ScopeRepoWrite, "other"), &priv, false, domain.ErrNotFound},
83		{"writer limited to another repo, public", tokenPrincipal(domain.ScopeRepoWrite, "other"), &pub, false, domain.ErrForbidden},
84		{"reader of a private repo", tokenPrincipal(domain.ScopeRepoRead), &priv, false, domain.ErrForbidden},
85		{"reader of a public repo", tokenPrincipal(domain.ScopeRepoRead), &pub, false, domain.ErrForbidden},
86		{"token with no relevant scope, private", tokenPrincipal(domain.ScopeMRWrite), &priv, false, domain.ErrNotFound},
87		{"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
88		{"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
89		{"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
90	}
91	for _, tt := range tests {
92		t.Run(tt.name, func(t *testing.T) {
93			f := newPushFixture(t)
94			if !tt.p.IsAnonymous() {
95				if tt.repo != nil {
96					f.meta.EXPECT().Get(ctx, n).Return(*tt.repo, nil)
97				} else {
98					f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
99				}
100			}
101			// The bus mock expects no Publish: nothing is created here.
102			err := f.pushes.Open(ctx, tt.p, n, tt.create)
103			if tt.want != nil {
104				assert.ErrorIs(t, err, tt.want)
105				return
106			}
107			require.NoError(t, err)
108		})
109	}
110
111	t.Run("creates a private repository and publishes RepoCreated", func(t *testing.T) {
112		f := newPushFixture(t)
113		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
114		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
115		want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
116		f.meta.EXPECT().Create(ctx, want).Return(nil)
117		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
118		f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()
119
120		require.NoError(t, f.pushes.Open(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, true))
121	})
122
123	t.Run("a concurrent first push wins: this one pushes to its repository", func(t *testing.T) {
124		f := newPushFixture(t)
125		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound).Once()
126		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
127		f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
128		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
129		f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
130
131		// The bus mock expects no Publish: the repository was not created here.
132		require.NoError(t, f.pushes.Open(ctx, domain.Root(), n, true))
133	})
134
135	t.Run("store failure", func(t *testing.T) {
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		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	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		f := newPushFixture(t) // the token store mock expects no call
162		require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups))
163	})
164
165	t.Run("a writer whose token still exists", func(t *testing.T) {
166		f := newPushFixture(t)
167		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
168		require.NoError(t, f.pushes.Check(ctx, writer, n, ups))
169	})
170
171	t.Run("a token revoked during the push is rejected", func(t *testing.T) {
172		f := newPushFixture(t)
173		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, domain.ErrNotFound)
174		assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)
175	})
176
177	t.Run("a token that expired during the push is rejected", func(t *testing.T) {
178		f := newPushFixture(t)
179		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: now.Add(-time.Second)}, nil)
180		assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)
181	})
182
183	t.Run("a store failure fails closed", func(t *testing.T) {
184		f := newPushFixture(t)
185		boom := errors.New("boom")
186		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, boom)
187		err := f.pushes.Check(ctx, writer, n, ups)
188		require.ErrorIs(t, err, boom)
189		assert.NotErrorIs(t, err, domain.ErrRejected, "an internal failure, not a refusal")
190	})
191
192	t.Run("a token without write access is rejected", func(t *testing.T) {
193		f := newPushFixture(t)
194		f.tokens.EXPECT().Get(ctx, "r1").Return(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)}, nil)
195		assert.ErrorIs(t, f.pushes.Check(ctx, reader, n, ups), domain.ErrRejected)
196	})
197
198	t.Run("anonymous is rejected", func(t *testing.T) {
199		f := newPushFixture(t)
200		assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
201	})
202}
203
204func TestPushesRecord(t *testing.T) {
205	ctx := context.Background()
206	n := name(t, "tools")
207	p := tokenPrincipal(domain.ScopeRepoWrite)
208	ups := []domain.RefUpdate{
209		{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/dev"},
210		{Old: domain.ZeroOID, New: oidB, Ref: "refs/heads/main"},
211		{Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"},
212	}
213
214	t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {
215		f := newPushFixture(t)
216		repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
217		f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
218		f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
219		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
220		f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
221
222		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
223		assert.Equal(t, []domain.RepoName{n}, f.sched.names)
224	})
225
226	t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
227		f := newPushFixture(t)
228		repo := domain.Repo{Name: n}
229		f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
230		f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
231		later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
232		f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
233		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
234		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
235		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
236	})
237
238	t.Run("a deleted repository is not an error", func(t *testing.T) {
239		f := newPushFixture(t)
240		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
241		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
242		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
243		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
244	})
245
246	t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) {
247		f := newPushFixture(t)
248		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
249		f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
250		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
251		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
252	})
253
254	t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) {
255		f := newPushFixture(t)
256		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
257		f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
258		f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
259		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
260		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
261	})
262
263	t.Run("every step runs when one fails", func(t *testing.T) {
264		f := newPushFixture(t)
265		boom := errors.New("boom")
266		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
267		f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
268		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
269		err := f.pushes.Record(ctx, p, n, ups)
270		assert.ErrorIs(t, err, boom)
271		assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled")
272	})
273}
274
275func TestPushesDefaultBranchRule(t *testing.T) {
276	ctx := context.Background()
277	n := name(t, "tools")
278	p := domain.Root()
279	up := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: oidA, Ref: ref} }
280	del := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: oidA, New: domain.ZeroOID, Ref: ref} }
281
282	tests := []struct {
283		name    string
284		head    string
285		exists  bool
286		updates []domain.RefUpdate
287		want    string // "" = HEAD untouched
288	}{
289		{"HEAD already valid", "main", true, []domain.RefUpdate{up("refs/heads/dev")}, ""},
290		{"main preferred", "trunk", false, []domain.RefUpdate{up("refs/heads/dev"), up("refs/heads/main")}, "main"},
291		{"first pushed branch", "main", false, []domain.RefUpdate{up("refs/tags/v1"), up("refs/heads/dev"), up("refs/heads/feature")}, "dev"},
292		{"HEAD names another missing branch", "trunk", false, []domain.RefUpdate{up("refs/heads/dev")}, "dev"},
293		{"only tags pushed", "main", false, []domain.RefUpdate{up("refs/tags/v1")}, ""},
294		{"deleted branches never become HEAD", "main", false, []domain.RefUpdate{del("refs/heads/old"), up("refs/heads/dev")}, "dev"},
295		{"only deletions", "main", false, []domain.RefUpdate{del("refs/heads/old")}, ""},
296		{"HEAD already points to the only candidate", "dev", false, []domain.RefUpdate{up("refs/heads/dev")}, ""},
297	}
298	for _, tt := range tests {
299		t.Run(tt.name, func(t *testing.T) {
300			f := newPushFixture(t)
301			f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
302			f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
303			if tt.want != "" {
304				f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
305			}
306			f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
307			require.NoError(t, f.pushes.Record(ctx, p, n, tt.updates))
308		})
309	}
310
311	t.Run("set failure is reported", func(t *testing.T) {
312		f := newPushFixture(t)
313		boom := errors.New("boom")
314		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
315		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
316		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
317		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
318		assert.ErrorIs(t, f.pushes.Record(ctx, p, n, []domain.RefUpdate{up("refs/heads/dev")}), boom)
319	})
320}
321
322// mockAny matches any argument of a mocked call.
323var mockAny = mock.Anything

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 43

↑↓ moveenter openesc close