v0.35.2 80d5067

releases_integration_test.go RawHistoryFolder

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().ListReleases(ctx, repo).Return(nil, nil).Twice()
267	store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()
268	store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()
269	store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
270	store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 2 && len(w.PutReleases) == 1 })).Return(nil).Once()
271	store.EXPECT().GetRelease(ctx, repo, int64(1)).Return(domain.Release{ID: 1, Repo: repo, Tag: "v1", Draft: true}, nil)
272	svc := usecase.NewReleases(store, nil, meta, reader, nil, nil, bus, clock, ports.ReleaseLimits{})
273	rel, err := svc.CreateRelease(ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v1", Draft: true})
274	require.NoError(t, err)
275	require.EqualValues(t, 1, rel.ID)
276}
277
278func TestReleaseListHidesDraftCursors(t *testing.T) {
279	r := newReleaseRig(t, ports.ReleaseLimits{})
280	pub := r.create(t, "v1", false)
281	d1 := r.create(t, "d1", true)
282	d2 := r.create(t, "d2", true)
283	anon := domain.Anonymous()
284
285	_, errDraft := r.svc.ListReleases(r.ctx, anon, r.repo, 1, fmt.Sprint(d1.ID))
286	_, errUnknown := r.svc.ListReleases(r.ctx, anon, r.repo, 1, "999999")
287	require.ErrorIs(t, errUnknown, domain.ErrInvalidPageToken)
288	require.Equal(t, errUnknown, errDraft)
289
290	// A writer still pages through drafts, newest first.
291	var got []int64
292	token := ""
293	for {
294		page, err := r.svc.ListReleases(r.ctx, domain.Root(), r.repo, 1, token)
295		require.NoError(t, err)
296		for _, rel := range page.Releases {
297			got = append(got, rel.ID)
298		}
299		if page.NextPageToken == "" {
300			break
301		}
302		token = page.NextPageToken
303	}
304	require.ElementsMatch(t, []int64{pub.ID, d1.ID, d2.ID}, got)
305}
306
307func TestReleaseUpdateTargetCreatesMissingTag(t *testing.T) {
308	r := newReleaseRig(t, ports.ReleaseLimits{})
309	r.resolve(t, "missing", domain.ErrNotFound)
310	draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true})
311	require.NoError(t, err)
312	no := false
313	_, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no})
314	require.ErrorIs(t, err, domain.ErrPreconditionFailed)
315
316	v, err := domain.ParseRevision("main")
317	require.NoError(t, err)
318	oid, err := domain.ParseObjectID(oidA)
319	require.NoError(t, err)
320	r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
321	r.tags.EXPECT().CreateTag(r.ctx, r.repo, "missing", oid).Return(nil).Once()
322	r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/missing", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
323	main := "main"
324	rel, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no, TargetCommitish: &main})
325	require.NoError(t, err)
326	require.False(t, rel.Draft)
327	require.Equal(t, oidA, rel.TargetCommitish)
328}
329
330func TestReleaseUpdateTargetNeverMovesExistingTag(t *testing.T) {
331	r := newReleaseRig(t, ports.ReleaseLimits{})
332	rel := r.create(t, "v1", false)
333	other := "other" // never resolved: the mock would fail the test
334	got, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, rel.ID, ports.UpdateReleaseParams{TargetCommitish: &other})
335	require.NoError(t, err)
336	require.Equal(t, oidA, got.TargetCommitish)
337}
338
339func TestReleaseLatestIndexTransitions(t *testing.T) {
340	r := newReleaseRig(t, ports.ReleaseLimits{})
341	latest := func(id int64) {
342		t.Helper()
343		got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
344		if id == 0 {
345			require.ErrorIs(t, err, domain.ErrNotFound)
346			return
347		}
348		require.NoError(t, err)
349		require.Equal(t, id, got.ID)
350	}
351	update := func(id int64, p ports.UpdateReleaseParams) {
352		t.Helper()
353		_, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, id, p)
354		require.NoError(t, err)
355	}
356	yes, no := true, false
357	draft := r.create(t, "draft", true)
358	latest(0)
359	first := r.create(t, "v1", false)
360	latest(first.ID)
361	update(draft.ID, ports.UpdateReleaseParams{Draft: &no})
362	latest(first.ID) // equal creation times: higher ID wins
363	second := r.create(t, "v2", false)
364	latest(second.ID)
365	update(second.ID, ports.UpdateReleaseParams{Prerelease: &yes})
366	latest(first.ID)
367	update(second.ID, ports.UpdateReleaseParams{Prerelease: &no})
368	latest(second.ID)
369	update(first.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
370	latest(first.ID)
371	tag := "renamed"
372	r.resolve(t, tag, nil)
373	update(first.ID, ports.UpdateReleaseParams{Tag: &tag})
374	latest(first.ID)
375	require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID))
376	latest(second.ID)
377	update(second.ID, ports.UpdateReleaseParams{Draft: &yes})
378	latest(draft.ID)
379	update(draft.ID, ports.UpdateReleaseParams{Draft: &yes})
380	latest(0)
381}
382
383// No ListReleases expectation: latest resolution must use only point reads.
384func TestReleaseLatestUsesIndex(t *testing.T) {
385	ctx := t.Context()
386	repo := name(t, "indexed")
387	store := mocks.NewReleaseStore(t)
388	meta := mocks.NewRepoMetaStore(t)
389	meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
390	store.EXPECT().GetLatestRelease(ctx, repo).Return(domain.Release{ID: 42, Repo: repo}, nil).Once()
391	svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{})
392	got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true})
393	require.NoError(t, err)
394	require.EqualValues(t, 42, got.ID)
395}

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