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