v0.8.0 eaf4295

repos_write_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
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/usecase"
19)
20
21func ptr[T any](v T) *T { return &v }
22
23func TestUpdate(t *testing.T) {
24	t.Parallel()
25	ctx := context.Background()
26	n := name(t, "tools")
27	priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
28	admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
29
30	t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
31		t.Parallel()
32		f := newFixture(t)
33		f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
34		want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
35		f.meta.EXPECT().Update(ctx, want).Return(nil)
36		f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
37			Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
38		}).Return().Once()
39
40		got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{
41			Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
42		})
43		require.NoError(t, err)
44		assert.Equal(t, want, got)
45	})
46
47	t.Run("a lost race on the metadata is retried", func(t *testing.T) {
48		t.Parallel()
49		f := newFixture(t)
50		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
51		f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
52		f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
53		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
54		_, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})
55		require.NoError(t, err)
56	})
57
58	t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
59		t.Parallel()
60		f := newFixture(t)
61		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
62		f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
63		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
64		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
65		got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
66		require.NoError(t, err)
67		assert.Equal(t, priv, got)
68	})
69
70	t.Run("metadata is written before HEAD moves, and the event follows both", func(t *testing.T) {
71		t.Parallel()
72		f := newFixture(t)
73		var order []string
74		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
75		f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
76		f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error {
77			order = append(order, "meta")
78			return nil
79		})
80		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
81			order = append(order, "head")
82			return nil
83		})
84		f.bus.EXPECT().Publish(ctx, mock.Anything).Run(func(context.Context, domain.Event) { order = append(order, "event") }).Return().Once()
85		_, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x"), DefaultBranch: ptr("dev")})
86		require.NoError(t, err)
87		assert.Equal(t, []string{"meta", "head", "event"}, order)
88	})
89
90	t.Run("a HEAD that cannot move after the metadata was stored still publishes, then fails", func(t *testing.T) {
91		t.Parallel()
92		f := newFixture(t)
93		boom := errors.New("boom")
94		pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
95		f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
96		f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
97		f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
98		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
99		f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
100			Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
101		}).Return().Once()
102		_, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Visibility: ptr(domain.VisibilityPrivate), DefaultBranch: ptr("dev")})
103		assert.ErrorIs(t, err, boom)
104	})
105
106	t.Run("a HEAD that cannot move with no metadata change publishes nothing", func(t *testing.T) {
107		t.Parallel()
108		f := newFixture(t)
109		boom := errors.New("boom")
110		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
111		f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
112		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
113		_, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
114		assert.ErrorIs(t, err, boom)
115	})
116
117	t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) {
118		t.Parallel()
119		f := newFixture(t)
120		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
121		f.git.EXPECT().HasBranch(ctx, n, "nope").Return(false, nil)
122		_, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x"), DefaultBranch: ptr("nope")})
123		assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
124	})
125
126	t.Run("invalid input changes nothing", func(t *testing.T) {
127		for name, params := range map[string]ports.UpdateRepoParams{
128			"empty":             {},
129			"empty branch":      {DefaultBranch: ptr("")},
130			"multi-line":        {Description: ptr("a\nb")},
131			"too long":          {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},
132			"bad utf-8":         {Description: ptr("\xff")},
133			"unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
134		} {
135			f := newFixture(t)
136			f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
137			_, err := f.repos.Update(ctx, admin, n, params)
138			require.Error(t, err, name)
139			assert.True(t, errors.Is(err, domain.ErrInvalid) || errors.Is(err, domain.ErrInvalidVisibility), "%s: %v", name, err)
140		}
141	})
142
143	access := []struct {
144		name string
145		p    domain.Principal
146		repo domain.Repo
147		want error
148	}{
149		{"anonymous on a private repository sees nothing", domain.Anonymous(), priv, domain.ErrNotFound},
150		{"anonymous on a public repository must log in", domain.Anonymous(), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrUnauthenticated},
151		{"reader of a private repository is forbidden", tokenPrincipal(domain.ScopeRepoRead), priv, domain.ErrForbidden},
152		{"writer of a private repository is hidden", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
153		{"admin of another repository is hidden", tokenPrincipal(domain.ScopeRepoAdmin, "other"), priv, domain.ErrNotFound},
154		{"writer of a public repository is forbidden", tokenPrincipal(domain.ScopeRepoWrite), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrForbidden},
155	}
156	for _, tt := range access {
157		t.Run(tt.name, func(t *testing.T) {
158			t.Parallel()
159			f := newFixture(t)
160			f.meta.EXPECT().Get(ctx, n).Return(tt.repo, nil)
161			_, err := f.repos.Update(ctx, tt.p, n, ports.UpdateRepoParams{Description: ptr("x")})
162			assert.ErrorIs(t, err, tt.want)
163			err = f.repos.Delete(ctx, tt.p, n)
164			assert.ErrorIs(t, err, tt.want)
165		})
166	}
167
168	t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
169		t.Parallel()
170		modes := []domain.MergeMode{domain.MergeModeSquash}
171		block := true
172		for name, params := range map[string]ports.UpdateRepoParams{
173			"default branch": {DefaultBranch: ptr("dev")},
174			"visibility":     {Visibility: ptr(domain.VisibilityPublic)},
175			"description":    {Description: ptr("x")},
176			"merge modes":    {AllowedMergeModes: &modes},
177			"checks rule":    {FailingChecksBlock: &block},
178		} {
179			f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
180			f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
181			_, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
182			assert.ErrorIs(t, err, domain.ErrForbidden, name)
183		}
184	})
185
186	t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
187		t.Parallel()
188		f := newFixture(t) // no Trash, no Delete
189		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
190		err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
191		assert.ErrorIs(t, err, domain.ErrForbidden)
192	})
193
194	t.Run("a missing repository is not found", func(t *testing.T) {
195		t.Parallel()
196		f := newFixture(t)
197		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
198		_, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Description: ptr("x")})
199		assert.ErrorIs(t, err, domain.ErrNotFound)
200	})
201}
202
203func TestDelete(t *testing.T) {
204	t.Parallel()
205	ctx := context.Background()
206	n := name(t, "tools")
207	priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
208
209	t.Run("trash, forget the metadata, publish RepoDeleted with the visibility", func(t *testing.T) {
210		t.Parallel()
211		f := newFixture(t)
212		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
213		f.git.EXPECT().Trash(ctx, n).Return(nil)
214		f.meta.EXPECT().Delete(ctx, n).Return(nil)
215		f.bus.EXPECT().Publish(ctx, domain.RepoDeleted{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
216		require.NoError(t, f.repos.Delete(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n))
217	})
218
219	t.Run("a directory already gone still loses its metadata", func(t *testing.T) {
220		t.Parallel()
221		f := newFixture(t)
222		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
223		f.git.EXPECT().Trash(ctx, n).Return(domain.ErrNotFound)
224		f.meta.EXPECT().Delete(ctx, n).Return(nil)
225		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
226		require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
227	})
228
229	t.Run("a failed move keeps the metadata", func(t *testing.T) {
230		t.Parallel()
231		f := newFixture(t)
232		boom := errors.New("boom")
233		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
234		f.git.EXPECT().Trash(ctx, n).Return(boom)
235		assert.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), boom)
236	})
237}
238
239func TestView(t *testing.T) {
240	t.Parallel()
241	ctx := context.Background()
242	n := name(t, "tools")
243	repo := domain.Repo{Name: n}
244
245	f := newFixture(t)
246	f.git.EXPECT().HeadBranch(ctx, n).Return("main", nil).Once()
247	v, err := f.repos.View(ctx, repo)
248	require.NoError(t, err)
249	assert.Equal(t, "main", v.DefaultBranch)
250
251	f.git.EXPECT().HeadBranch(ctx, n).Return("", domain.ErrNotFound).Once()
252	v, err = f.repos.View(ctx, repo)
253	require.NoError(t, err, "a missing directory has no default branch")
254	assert.Empty(t, v.DefaultBranch)
255}
256
257func TestListPageSizes(t *testing.T) {
258	t.Parallel()
259	ctx := context.Background()
260	var all []domain.Repo
261	for i := range usecase.MaxRepoPageSize + 10 {
262		all = append(all, domain.Repo{Name: name(t, fmt.Sprintf("r%04d", i)), Visibility: domain.VisibilityPublic})
263	}
264	for _, tt := range []struct {
265		name string
266		size int
267		want int
268		more bool
269	}{
270		{"default", 0, usecase.DefaultPageSize, true},
271		{"within the maximum", 500, 500, true},
272		{"the maximum", usecase.MaxRepoPageSize, usecase.MaxRepoPageSize, true},
273		{"lowered to the maximum", usecase.MaxRepoPageSize * 10, usecase.MaxRepoPageSize, true},
274	} {
275		t.Run(tt.name, func(t *testing.T) {
276			t.Parallel()
277			f := newFixture(t)
278			f.meta.EXPECT().List(ctx).Return(all, nil)
279			page, err := f.repos.ListPage(ctx, domain.Anonymous(), tt.size, "")
280			require.NoError(t, err)
281			assert.Len(t, page.Repos, tt.want)
282			assert.Equal(t, tt.more, page.NextPageToken != "")
283		})
284	}
285}
286
287func TestListPage(t *testing.T) {
288	t.Parallel()
289	ctx := context.Background()
290	var all []domain.Repo
291	for _, s := range []string{"a", "b", "c", "d", "e"} {
292		all = append(all, domain.Repo{Name: name(t, s), Visibility: domain.VisibilityPublic})
293	}
294	priv := domain.Repo{Name: name(t, "bb"), Visibility: domain.VisibilityPrivate}
295	all = append(all[:2], append([]domain.Repo{priv}, all[2:]...)...)
296
297	f := newFixture(t)
298	f.meta.EXPECT().List(ctx).Return(all, nil)
299	var got []string
300	token := ""
301	for pages := 0; ; pages++ {
302		require.Less(t, pages, 10)
303		page, err := f.repos.ListPage(ctx, domain.Anonymous(), 2, token)
304		require.NoError(t, err)
305		for _, r := range page.Repos {
306			got = append(got, r.Name.String())
307		}
308		if token = page.NextPageToken; token == "" {
309			break
310		}
311	}
312	assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got, "private repositories never show, pages do not overlap")
313
314	_, err := f.repos.ListPage(ctx, domain.Root(), 0, "garbage")
315	assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
316	_, err = f.repos.ListPage(ctx, domain.Root(), -1, "")
317	assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
318}

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