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}
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