1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6 "context"
7 "errors"
8 "strings"
9 "testing"
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/app"
16 "bnema.dev/ermite/internal/domain"
17)
18
19func ptr[T any](v T) *T { return &v }
20
21func TestUpdate(t *testing.T) {
22 ctx := context.Background()
23 n := name(t, "tools")
24 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
25 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
26
27 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
28 f := newFixture(t)
29 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
30 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
31 f.meta.EXPECT().Update(ctx, want).Return(nil)
32 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
33 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
34 }).Return().Once()
35
36 got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{
37 Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
38 })
39 require.NoError(t, err)
40 assert.Equal(t, want, got)
41 })
42
43 t.Run("a lost race on the metadata is retried", func(t *testing.T) {
44 f := newFixture(t)
45 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
46 f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
47 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
48 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
49 _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x")})
50 require.NoError(t, err)
51 })
52
53 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
54 f := newFixture(t)
55 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
56 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
57 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
58 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
59 got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{DefaultBranch: ptr("dev")})
60 require.NoError(t, err)
61 assert.Equal(t, priv, got)
62 })
63
64 t.Run("metadata is written before HEAD moves, and the event follows both", func(t *testing.T) {
65 f := newFixture(t)
66 var order []string
67 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
68 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
69 f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error {
70 order = append(order, "meta")
71 return nil
72 })
73 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
74 order = append(order, "head")
75 return nil
76 })
77 f.bus.EXPECT().Publish(ctx, mock.Anything).Run(func(context.Context, domain.Event) { order = append(order, "event") }).Return().Once()
78 _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("dev")})
79 require.NoError(t, err)
80 assert.Equal(t, []string{"meta", "head", "event"}, order)
81 })
82
83 t.Run("a HEAD that cannot move after the metadata was stored still publishes, then fails", func(t *testing.T) {
84 f := newFixture(t)
85 boom := errors.New("boom")
86 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
87 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
88 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
89 f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
90 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
91 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
92 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
93 }).Return().Once()
94 _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Visibility: ptr(domain.VisibilityPrivate), DefaultBranch: ptr("dev")})
95 assert.ErrorIs(t, err, boom)
96 })
97
98 t.Run("a HEAD that cannot move with no metadata change publishes nothing", func(t *testing.T) {
99 f := newFixture(t)
100 boom := errors.New("boom")
101 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
102 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
103 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
104 _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{DefaultBranch: ptr("dev")})
105 assert.ErrorIs(t, err, boom)
106 })
107
108 t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) {
109 f := newFixture(t)
110 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
111 f.git.EXPECT().HasBranch(ctx, n, "nope").Return(false, nil)
112 _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("nope")})
113 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
114 })
115
116 t.Run("invalid input changes nothing", func(t *testing.T) {
117 for name, params := range map[string]app.UpdateParams{
118 "empty": {},
119 "empty branch": {DefaultBranch: ptr("")},
120 "multi-line": {Description: ptr("a\nb")},
121 "too long": {Description: ptr(strings.Repeat("x", app.MaxDescriptionLen+1))},
122 "bad utf-8": {Description: ptr("\xff")},
123 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
124 } {
125 f := newFixture(t)
126 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
127 _, err := f.repos.Update(ctx, admin, n, params)
128 require.Error(t, err, name)
129 assert.True(t, errors.Is(err, domain.ErrInvalid) || errors.Is(err, domain.ErrInvalidVisibility), "%s: %v", name, err)
130 }
131 })
132
133 access := []struct {
134 name string
135 p domain.Principal
136 repo domain.Repo
137 want error
138 }{
139 {"anonymous on a private repository sees nothing", domain.Anonymous(), priv, domain.ErrNotFound},
140 {"anonymous on a public repository must log in", domain.Anonymous(), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrUnauthenticated},
141 {"reader of a private repository is forbidden", tokenPrincipal(domain.ScopeRepoRead), priv, domain.ErrForbidden},
142 {"writer of a private repository is hidden", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
143 {"admin of another repository is hidden", tokenPrincipal(domain.ScopeRepoAdmin, "other"), priv, domain.ErrNotFound},
144 {"writer of a public repository is forbidden", tokenPrincipal(domain.ScopeRepoWrite), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrForbidden},
145 }
146 for _, tt := range access {
147 t.Run(tt.name, func(t *testing.T) {
148 f := newFixture(t)
149 f.meta.EXPECT().Get(ctx, n).Return(tt.repo, nil)
150 _, err := f.repos.Update(ctx, tt.p, n, app.UpdateParams{Description: ptr("x")})
151 assert.ErrorIs(t, err, tt.want)
152 err = f.repos.Delete(ctx, tt.p, n)
153 assert.ErrorIs(t, err, tt.want)
154 })
155 }
156
157 t.Run("a missing repository is not found", func(t *testing.T) {
158 f := newFixture(t)
159 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
160 _, err := f.repos.Update(ctx, domain.Root(), n, app.UpdateParams{Description: ptr("x")})
161 assert.ErrorIs(t, err, domain.ErrNotFound)
162 })
163}
164
165func TestDelete(t *testing.T) {
166 ctx := context.Background()
167 n := name(t, "tools")
168 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
169
170 t.Run("trash, forget the metadata, publish RepoDeleted with the visibility", func(t *testing.T) {
171 f := newFixture(t)
172 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
173 f.git.EXPECT().Trash(ctx, n).Return(nil)
174 f.meta.EXPECT().Delete(ctx, n).Return(nil)
175 f.bus.EXPECT().Publish(ctx, domain.RepoDeleted{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
176 require.NoError(t, f.repos.Delete(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n))
177 })
178
179 t.Run("a directory already gone still loses its metadata", func(t *testing.T) {
180 f := newFixture(t)
181 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
182 f.git.EXPECT().Trash(ctx, n).Return(domain.ErrNotFound)
183 f.meta.EXPECT().Delete(ctx, n).Return(nil)
184 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
185 require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
186 })
187
188 t.Run("a failed move keeps the metadata", func(t *testing.T) {
189 f := newFixture(t)
190 boom := errors.New("boom")
191 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
192 f.git.EXPECT().Trash(ctx, n).Return(boom)
193 assert.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), boom)
194 })
195}
196
197func TestView(t *testing.T) {
198 ctx := context.Background()
199 n := name(t, "tools")
200 repo := domain.Repo{Name: n}
201
202 f := newFixture(t)
203 f.git.EXPECT().HeadBranch(ctx, n).Return("main", nil).Once()
204 v, err := f.repos.View(ctx, repo)
205 require.NoError(t, err)
206 assert.Equal(t, "main", v.DefaultBranch)
207
208 f.git.EXPECT().HeadBranch(ctx, n).Return("", domain.ErrNotFound).Once()
209 v, err = f.repos.View(ctx, repo)
210 require.NoError(t, err, "a missing directory has no default branch")
211 assert.Empty(t, v.DefaultBranch)
212}
213
214func TestListPage(t *testing.T) {
215 ctx := context.Background()
216 var all []domain.Repo
217 for _, s := range []string{"a", "b", "c", "d", "e"} {
218 all = append(all, domain.Repo{Name: name(t, s), Visibility: domain.VisibilityPublic})
219 }
220 priv := domain.Repo{Name: name(t, "bb"), Visibility: domain.VisibilityPrivate}
221 all = append(all[:2], append([]domain.Repo{priv}, all[2:]...)...)
222
223 f := newFixture(t)
224 f.meta.EXPECT().List(ctx).Return(all, nil)
225 var got []string
226 token := ""
227 for pages := 0; ; pages++ {
228 require.Less(t, pages, 10)
229 page, err := f.repos.ListPage(ctx, domain.Anonymous(), 2, token)
230 require.NoError(t, err)
231 for _, r := range page.Repos {
232 got = append(got, r.Name.String())
233 }
234 if token = page.NextPageToken; token == "" {
235 break
236 }
237 }
238 assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got, "private repositories never show, pages do not overlap")
239
240 _, err := f.repos.ListPage(ctx, domain.Root(), 0, "garbage")
241 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
242 _, err = f.repos.ListPage(ctx, domain.Root(), -1, "")
243 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
244}