1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "fmt"
8 "testing"
9 "testing/synctest"
10
11 "bnema.dev/ermite/internal/adapters/blobdisk"
12 "bnema.dev/ermite/internal/adapters/eventbus"
13 "bnema.dev/ermite/internal/adapters/kvstore"
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16 "bnema.dev/ermite/internal/ports/mocks"
17 "bnema.dev/ermite/internal/usecase"
18 "github.com/stretchr/testify/mock"
19 "github.com/stretchr/testify/require"
20)
21
22type releaseRig struct {
23 ctx context.Context
24 repo domain.RepoName
25 store *kvstore.Store
26 blobs ports.BlobStore
27 reader *mocks.RepoReader
28 tags *mocks.ReleaseTagWriter
29 pushes *mocks.PushHookService
30 clock *mocks.Clock
31 events <-chan domain.Event
32 svc *usecase.Releases
33}
34
35func newReleaseRig(t *testing.T, limits ports.ReleaseLimits) *releaseRig {
36 t.Helper()
37 ctx := t.Context()
38 store, err := kvstore.Open(ctx, t.TempDir())
39 require.NoError(t, err)
40 t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
41 n := name(t, "releases")
42 require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now}))
43 clock := mocks.NewClock(t)
44 clock.EXPECT().Now().Return(now).Maybe()
45 reader := mocks.NewRepoReader(t)
46 tags := mocks.NewReleaseTagWriter(t)
47 pushes := mocks.NewPushHookService(t)
48 bus := eventbus.NewWithBuffer(1024)
49 b := blobdisk.New(t.TempDir())
50 r := &releaseRig{ctx: ctx, repo: n, store: store, blobs: b, reader: reader, tags: tags, pushes: pushes, clock: clock, events: bus.Subscribe(ctx)}
51 r.svc = usecase.NewReleases(store.Releases(), b, store, reader, tags, pushes, bus, clock, limits)
52 return r
53}
54func (r *releaseRig) resolve(t *testing.T, tag string, err error) {
55 t.Helper()
56 v, e := domain.ParseRevision("refs/tags/" + tag)
57 require.NoError(t, e)
58 oid, e := domain.ParseObjectID(oidA)
59 require.NoError(t, e)
60 r.reader.EXPECT().ResolveRevision(mock.Anything, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, err).Maybe()
61}
62func (r *releaseRig) create(t *testing.T, tag string, draft bool) domain.Release {
63 t.Helper()
64 r.resolve(t, tag, nil)
65 rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: tag, Draft: draft})
66 require.NoError(t, err)
67 return rel
68}
69func TestReleaseAccessMatrix(t *testing.T) {
70 for _, private := range []bool{false, true} {
71 t.Run(fmt.Sprint("private=", private), func(t *testing.T) {
72 r := newReleaseRig(t, ports.ReleaseLimits{})
73 published := r.create(t, "v1", false)
74 draft := r.create(t, "draft", true)
75 if private {
76 _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Visibility = domain.VisibilityPrivate; return true, nil })
77 require.NoError(t, err)
78 }
79 token := func(scopes []domain.Scope, branches ...string) domain.Principal {
80 return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, BranchPrefixes: branches})
81 }
82 for _, tc := range []struct {
83 name string
84 p domain.Principal
85 read, write bool
86 }{
87 {"anonymous", domain.Anonymous(), !private, false},
88 {"reader", token([]domain.Scope{domain.ScopeRepoRead}), true, false},
89 {"writer", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}), true, true},
90 {"write-only", token([]domain.Scope{domain.ScopeReleaseWrite}), !private, !private},
91 {"branch", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, "x/"), true, false},
92 {"job-read", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "read"}}), true, false},
93 {"job-write", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "write"}}), true, true},
94 {"owner", domain.Owner(), true, true},
95 } {
96 t.Run(tc.name, func(t *testing.T) {
97 _, err := r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: published.ID})
98 if tc.read {
99 require.NoError(t, err)
100 } else {
101 require.ErrorIs(t, err, domain.ErrNotFound)
102 }
103 _, err = r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: draft.ID})
104 if tc.write {
105 require.NoError(t, err)
106 } else {
107 require.ErrorIs(t, err, domain.ErrNotFound)
108 }
109 title := "changed"
110 _, err = r.svc.UpdateRelease(r.ctx, tc.p, r.repo, published.ID, ports.UpdateReleaseParams{Title: &title})
111 switch {
112 case tc.write:
113 require.NoError(t, err)
114 case !tc.read:
115 require.ErrorIs(t, err, domain.ErrNotFound)
116 case tc.p.IsAnonymous():
117 require.ErrorIs(t, err, domain.ErrUnauthenticated)
118 default:
119 require.ErrorIs(t, err, domain.ErrForbidden)
120 }
121 })
122 }
123 })
124 }
125}
126func TestReleaseLifecycle(t *testing.T) {
127 r := newReleaseRig(t, ports.ReleaseLimits{})
128 first := r.create(t, "v1", false)
129 second := r.create(t, "v2", false)
130 r.create(t, "draft", true)
131 got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
132 require.NoError(t, err)
133 require.Equal(t, second.ID, got.ID)
134 yes := true
135 _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, first.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
136 require.NoError(t, err)
137 got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true})
138 require.NoError(t, err)
139 require.Equal(t, first.ID, got.ID)
140 _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "v1"})
141 require.ErrorIs(t, err, domain.ErrAlreadyExists)
142 page, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, "")
143 require.NoError(t, err)
144 require.Len(t, page.Releases, 1)
145 require.NotEmpty(t, page.NextPageToken)
146 next, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, page.NextPageToken)
147 require.NoError(t, err)
148 require.Equal(t, first.ID, next.Releases[0].ID)
149 require.Empty(t, next.NextPageToken)
150 r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: domain.ZeroOID}})
151 got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
152 require.NoError(t, err)
153 require.True(t, got.Draft)
154 require.True(t, got.PublishedAt.IsZero())
155 require.False(t, got.MakeLatest)
156 r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: oidA}})
157 got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
158 require.NoError(t, err)
159 require.True(t, got.Draft)
160 var kinds []string
161 for len(r.events) > 0 {
162 kinds = append(kinds, (<-r.events).EventKind())
163 }
164 require.Contains(t, kinds, "release.unpublished")
165 require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID))
166 _, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID})
167 require.ErrorIs(t, err, domain.ErrNotFound)
168}
169func TestReleaseMissingTag(t *testing.T) {
170 r := newReleaseRig(t, ports.ReleaseLimits{})
171 r.resolve(t, "missing", domain.ErrNotFound)
172 draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true})
173 require.NoError(t, err)
174 require.Empty(t, draft.TargetCommitish)
175 no := false
176 _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no})
177 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
178 r.resolve(t, "new", domain.ErrNotFound)
179 _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new"})
180 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
181 v, err := domain.ParseRevision("main")
182 require.NoError(t, err)
183 oid, err := domain.ParseObjectID(oidA)
184 require.NoError(t, err)
185 r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
186 r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once()
187 r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
188 rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new", TargetCommitish: "main"})
189 require.NoError(t, err)
190 require.Equal(t, oidA, rel.TargetCommitish)
191}
192
193func TestReleaseLatestPinsAndPrereleases(t *testing.T) {
194 r := newReleaseRig(t, ports.ReleaseLimits{})
195 a := r.create(t, "a", false)
196 b := r.create(t, "b", false)
197 yes := true
198 _, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateReleaseParams{Prerelease: &yes})
199 require.NoError(t, err)
200 latest, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
201 require.NoError(t, err)
202 require.Equal(t, a.ID, latest.ID)
203 _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, a.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
204 require.NoError(t, err)
205 r.resolve(t, "c", nil)
206 c, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "c", MakeLatest: true})
207 require.NoError(t, err)
208 a, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: a.ID})
209 require.NoError(t, err)
210 require.False(t, a.MakeLatest)
211 target := "b"
212 _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, c.ID, ports.UpdateReleaseParams{Tag: &target})
213 require.ErrorIs(t, err, domain.ErrAlreadyExists)
214 require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, c.ID))
215 latest, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true})
216 require.NoError(t, err)
217 require.Equal(t, a.ID, latest.ID)
218}
219
220func TestReleaseEventsReachHomeAndRepositoryTopics(t *testing.T) {
221 synctest.Test(t, func(t *testing.T) {
222 r := newEventRig(t, 0)
223 pub := r.repo("pub", domain.VisibilityPublic)
224 priv := r.repo("priv", domain.VisibilityPrivate)
225 ctx, cancel := context.WithCancel(context.Background())
226 defer cancel()
227 anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}})
228 owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
229 synctest.Wait()
230 for _, n := range []domain.RepoName{priv, pub} {
231 vis := domain.VisibilityPublic
232 if n == priv {
233 vis = domain.VisibilityPrivate
234 }
235 rel := domain.Release{ID: 1, Repo: n, Tag: "v1"}
236 r.feed <- domain.ReleasePublished{Release: rel, At: now, CreatedAt: now, Visibility: vis}
237 r.feed <- domain.ReleaseUpdated{Release: rel, At: now, CreatedAt: now, Visibility: vis}
238 r.feed <- domain.ReleaseDeleted{Release: rel, At: now, CreatedAt: now, Visibility: vis}
239 r.feed <- domain.ReleaseUnpublished{Release: rel, At: now, CreatedAt: now, Visibility: vis}
240 }
241 for _, w := range []*watcher{anon, owner} {
242 for _, kind := range []string{"release.published", "release.updated", "release.deleted", "release.unpublished"} {
243 require.Equal(t, kind, w.next(t).Event.EventKind())
244 }
245 }
246 anon.quiet(t, r, pub)
247 owner.quiet(t, r, priv)
248 })
249}
250
251func TestReleaseWriteRetriesUsageConflict(t *testing.T) {
252 ctx := t.Context()
253 repo := name(t, "retry")
254 store := mocks.NewReleaseStore(t)
255 meta := mocks.NewRepoMetaStore(t)
256 reader := mocks.NewRepoReader(t)
257 clock := mocks.NewClock(t)
258 bus := mocks.NewEventBus(t)
259 meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil)
260 clock.EXPECT().Now().Return(now).Maybe()
261 rev, err := domain.ParseRevision("refs/tags/v1")
262 require.NoError(t, err)
263 reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound)
264 store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound)
265 store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil)
266 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()
267 store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()
268 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
269 store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 2 && len(w.PutReleases) == 1 })).Return(nil).Once()
270 store.EXPECT().GetRelease(ctx, repo, int64(1)).Return(domain.Release{ID: 1, Repo: repo, Tag: "v1", Draft: true}, nil)
271 svc := usecase.NewReleases(store, nil, meta, reader, nil, nil, bus, clock, ports.ReleaseLimits{})
272 rel, err := svc.CreateRelease(ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v1", Draft: true})
273 require.NoError(t, err)
274 require.EqualValues(t, 1, rel.ID)
275}
276
277func TestReleaseListHidesDraftCursors(t *testing.T) {
278 r := newReleaseRig(t, ports.ReleaseLimits{})
279 pub := r.create(t, "v1", false)
280 d1 := r.create(t, "d1", true)
281 d2 := r.create(t, "d2", true)
282 anon := domain.Anonymous()
283
284 _, errDraft := r.svc.ListReleases(r.ctx, anon, r.repo, 1, fmt.Sprint(d1.ID))
285 _, errUnknown := r.svc.ListReleases(r.ctx, anon, r.repo, 1, "999999")
286 require.ErrorIs(t, errUnknown, domain.ErrInvalidPageToken)
287 require.Equal(t, errUnknown, errDraft)
288
289 // A writer still pages through drafts, newest first.
290 var got []int64
291 token := ""
292 for {
293 page, err := r.svc.ListReleases(r.ctx, domain.Root(), r.repo, 1, token)
294 require.NoError(t, err)
295 for _, rel := range page.Releases {
296 got = append(got, rel.ID)
297 }
298 if page.NextPageToken == "" {
299 break
300 }
301 token = page.NextPageToken
302 }
303 require.ElementsMatch(t, []int64{pub.ID, d1.ID, d2.ID}, got)
304}
305
306func TestReleaseUpdateTargetCreatesMissingTag(t *testing.T) {
307 r := newReleaseRig(t, ports.ReleaseLimits{})
308 r.resolve(t, "missing", domain.ErrNotFound)
309 draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true})
310 require.NoError(t, err)
311 no := false
312 _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no})
313 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
314
315 v, err := domain.ParseRevision("main")
316 require.NoError(t, err)
317 oid, err := domain.ParseObjectID(oidA)
318 require.NoError(t, err)
319 r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
320 r.tags.EXPECT().CreateTag(r.ctx, r.repo, "missing", oid).Return(nil).Once()
321 r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/missing", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
322 main := "main"
323 rel, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no, TargetCommitish: &main})
324 require.NoError(t, err)
325 require.False(t, rel.Draft)
326 require.Equal(t, oidA, rel.TargetCommitish)
327}
328
329func TestReleaseUpdateTargetNeverMovesExistingTag(t *testing.T) {
330 r := newReleaseRig(t, ports.ReleaseLimits{})
331 rel := r.create(t, "v1", false)
332 other := "other" // never resolved: the mock would fail the test
333 got, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, rel.ID, ports.UpdateReleaseParams{TargetCommitish: &other})
334 require.NoError(t, err)
335 require.Equal(t, oidA, got.TargetCommitish)
336}