v0.6.0 d5cc8fe

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

↑↓ moveenter openesc close