v0.34.0 7c1886f

release_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"bytes"
7	"context"
8	"io"
9	"net/http/httptest"
10	"testing"
11	"time"
12
13	"connectrpc.com/connect"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17	"google.golang.org/protobuf/proto"
18	"google.golang.org/protobuf/types/known/fieldmaskpb"
19
20	pb "bnema.dev/ermite-api/gen/ermite"
21	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22	"bnema.dev/ermite/internal/domain"
23	"bnema.dev/ermite/internal/ports"
24	"bnema.dev/ermite/internal/ports/mocks"
25)
26
27func releaseClient(t *testing.T, rel ports.ReleaseService) (ermiteconnect.ReleaseServiceClient, ermiteconnect.ServerServiceClient) {
28	t.Helper()
29	srv := httptest.NewUnstartedServer(New(Config{Releases: rel, Sessions: mocks.NewSessionService(t), SigningKey: mocks.NewSigningKeyService(t)}).Handler())
30	srv.EnableHTTP2 = true
31	srv.StartTLS()
32	t.Cleanup(srv.Close)
33	return ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL), ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
34}
35
36func TestReleaseRPCUnary(t *testing.T) {
37	ctx := context.Background()
38	repo := mustRepoName(t, "demo")
39	now := time.Now().UTC().Truncate(time.Second)
40	r := domain.Release{ID: 7, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", Prerelease: true, TargetCommitish: "abc", Author: "owner", CreatedAt: now, PublishedAt: now, UpdatedAt: now}
41	a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Repo: repo, Name: "a.zip", Label: "label", ContentType: "application/zip", Size: 3, Digest: "sha256:123", Uploader: "owner", CreatedAt: now, UpdatedAt: now}
42	expected := releaseToPB(r)
43	expected.Latest = true
44	expected.Assets = []*pb.ReleaseAsset{assetToPB(a)}
45	for _, method := range []string{"create", "get id", "get tag", "get latest", "list", "update", "delete", "update asset", "delete asset", "list assets", "usage"} {
46		t.Run(method, func(t *testing.T) {
47			rel := mocks.NewReleaseService(t)
48			c, _ := releaseClient(t, rel)
49			p := domain.Anonymous()
50			hydrate := func() {
51				rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(r, nil).Once()
52				rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return([]domain.ReleaseAsset{a}, nil).Once()
53			}
54			switch method {
55			case "create":
56				rel.EXPECT().CreateRelease(mock.Anything, p, repo, ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}).Return(r, nil).Once()
57				hydrate()
58				res, err := c.CreateRelease(ctx, connect.NewRequest(&pb.CreateReleaseRequest{Repo: "demo", Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}))
59				require.NoError(t, err)
60				assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
61			case "get id", "get tag", "get latest":
62				req := &pb.GetReleaseRequest{Repo: "demo"}
63				ref := ports.ReleaseRef{}
64				switch method {
65				case "get id":
66					req.Selector = &pb.GetReleaseRequest_Id{Id: 7}
67					ref.ID = 7
68				case "get tag":
69					req.Selector = &pb.GetReleaseRequest_Tag{Tag: "v1"}
70					ref.Tag = "v1"
71				default:
72					req.Selector = &pb.GetReleaseRequest_Latest{Latest: true}
73					ref.Latest = true
74				}
75				rel.EXPECT().GetRelease(mock.Anything, p, repo, ref).Return(r, nil).Once()
76				hydrate()
77				res, err := c.GetRelease(ctx, connect.NewRequest(req))
78				require.NoError(t, err)
79				assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
80			case "list":
81				rel.EXPECT().ListReleases(mock.Anything, p, repo, 17, "cursor").Return(ports.ReleasePage{Releases: []domain.Release{r}, NextPageToken: "next"}, nil).Once()
82				hydrate()
83				res, err := c.ListReleases(ctx, connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo", PageSize: 17, PageToken: "cursor"}))
84				require.NoError(t, err)
85				require.Len(t, res.Msg.Releases, 1)
86				assert.True(t, proto.Equal(expected, res.Msg.Releases[0]))
87				assert.Equal(t, "next", res.Msg.NextPageToken)
88			case "update":
89				rel.EXPECT().UpdateRelease(mock.Anything, p, repo, int64(7), ports.UpdateReleaseParams{Title: new(""), Draft: new(false)}).Return(r, nil).Once()
90				hydrate()
91				res, err := c.UpdateRelease(ctx, connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", Id: 7, Tag: "ignored", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title", "draft"}}}))
92				require.NoError(t, err)
93				assert.True(t, proto.Equal(expected, res.Msg.GetRelease()))
94			case "delete":
95				rel.EXPECT().DeleteRelease(mock.Anything, p, repo, int64(7)).Return(nil).Once()
96				_, err := c.DeleteRelease(ctx, connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7}))
97				require.NoError(t, err)
98			case "update asset":
99				rel.EXPECT().UpdateAsset(mock.Anything, p, repo, int64(9), ports.UpdateAssetParams{Name: new("new.zip"), Label: new("")}).Return(a, nil).Once()
100				res, err := c.UpdateReleaseAsset(ctx, connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo", AssetId: 9, Name: "new.zip", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name", "label"}}}))
101				require.NoError(t, err)
102				assert.True(t, proto.Equal(assetToPB(a), res.Msg.GetAsset()))
103			case "delete asset":
104				rel.EXPECT().DeleteAsset(mock.Anything, p, repo, int64(9)).Return(nil).Once()
105				_, err := c.DeleteReleaseAsset(ctx, connect.NewRequest(&pb.DeleteReleaseAssetRequest{Repo: "demo", AssetId: 9}))
106				require.NoError(t, err)
107			case "list assets":
108				rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(7)).Return([]domain.ReleaseAsset{a}, nil).Once()
109				res, err := c.ListReleaseAssets(ctx, connect.NewRequest(&pb.ListReleaseAssetsRequest{Repo: "demo", ReleaseId: 7}))
110				require.NoError(t, err)
111				require.Len(t, res.Msg.Assets, 1)
112				assert.True(t, proto.Equal(assetToPB(a), res.Msg.Assets[0]))
113			case "usage":
114				rel.EXPECT().Usage(mock.Anything, p, repo).Return(ports.ReleaseUsage{Used: 1, Limits: ports.ReleaseLimits{Quota: 2, AssetMax: 3, AssetsMax: 4}}, nil).Once()
115				res, err := c.GetReleaseUsage(ctx, connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"}))
116				require.NoError(t, err)
117				assert.True(t, proto.Equal(&pb.GetReleaseUsageResponse{UsedBytes: 1, QuotaBytes: 2, AssetMaxBytes: 3, AssetsMax: 4}, res.Msg))
118			}
119		})
120	}
121}
122
123func TestReleaseFeatureAndMount(t *testing.T) {
124	for _, enabled := range []bool{false, true} {
125		t.Run(map[bool]string{false: "absent", true: "present"}[enabled], func(t *testing.T) {
126			var rel ports.ReleaseService
127			if enabled {
128				rel = mocks.NewReleaseService(t)
129			}
130			c, server := releaseClient(t, rel)
131			res, err := server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
132			require.NoError(t, err)
133			assert.Equal(t, enabled, containsReleaseFeature(res.Msg.Features))
134			assert.NotContains(t, Features, FeatureReleases)
135			if !enabled {
136				_, err := c.GetReleaseUsage(context.Background(), connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"}))
137				requireCode(t, connect.CodeUnimplemented, err)
138			}
139		})
140	}
141}
142func containsReleaseFeature(features []string) bool {
143	for _, f := range features {
144		if f == FeatureReleases {
145			return true
146		}
147	}
148	return false
149}
150
151func TestReleaseRPCUpload(t *testing.T) {
152	for _, firstData := range []bool{false, true} {
153		t.Run(map[bool]string{false: "header only", true: "header with data"}[firstData], func(t *testing.T) {
154			rel := mocks.NewReleaseService(t)
155			c, _ := releaseClient(t, rel)
156			full := bytes.Repeat([]byte("x"), domain.CIArtifactChunk)
157			tail := []byte("tail")
158			want := append(bytes.Clone(full), tail...)
159			rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.UploadAssetParams{ReleaseID: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) {
160				got, err := io.ReadAll(r)
161				assert.NoError(t, err)
162				assert.Equal(t, want, got)
163				return domain.ReleaseAsset{ID: 9, Size: int64(len(got))}, err
164			}).Once()
165			stream := c.UploadReleaseAsset(context.Background())
166			first := &pb.UploadReleaseAssetRequest{Repo: "demo", ReleaseId: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}
167			if firstData {
168				first.Data = full
169			}
170			require.NoError(t, stream.Send(first))
171			if !firstData {
172				require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: full}))
173			}
174			require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: tail}))
175			res, err := stream.CloseAndReceive()
176			require.NoError(t, err)
177			assert.EqualValues(t, 9, res.Msg.Asset.Id)
178			assert.EqualValues(t, len(want), res.Msg.Asset.Size)
179		})
180	}
181}
182
183func TestReleaseRPCUploadInvalid(t *testing.T) {
184	tests := []struct {
185		name     string
186		messages []*pb.UploadReleaseAssetRequest
187		read     bool
188		code     connect.Code
189	}{
190		{name: "empty", code: connect.CodeInvalidArgument},
191		{name: "negative size", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Size: -1}}, code: connect.CodeInvalidArgument},
192		{name: "bad repo", messages: []*pb.UploadReleaseAssetRequest{{Repo: "../bad"}}, code: connect.CodeInvalidArgument},
193		{name: "first chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, domain.CIArtifactChunk+1)}}, code: connect.CodeInvalidArgument},
194		{name: "later chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {Data: make([]byte, domain.CIArtifactChunk+1)}}, read: true, code: connect.CodeInvalidArgument},
195		{name: "message exceeds envelope", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, RunnerMaxRequestBytes+1)}}, code: connect.CodeResourceExhausted},
196	}
197	for _, m := range []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {ReleaseId: 1}, {Name: "a"}, {Label: "l"}, {ContentType: "text/plain"}, {Size: 1}, {Replace: true}} {
198		tests = append(tests, struct {
199			name     string
200			messages []*pb.UploadReleaseAssetRequest
201			read     bool
202			code     connect.Code
203		}{name: "repeated metadata " + m.String(), messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, m}, read: true, code: connect.CodeInvalidArgument})
204	}
205	for _, tt := range tests {
206		t.Run(tt.name, func(t *testing.T) {
207			rel := mocks.NewReleaseService(t)
208			c, _ := releaseClient(t, rel)
209			if tt.read {
210				rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) {
211					_, err := io.ReadAll(r)
212					return domain.ReleaseAsset{}, err
213				}).Once()
214			}
215			stream := c.UploadReleaseAsset(context.Background())
216			for _, m := range tt.messages {
217				if err := stream.Send(m); err != nil {
218					break
219				}
220			}
221			_, err := stream.CloseAndReceive()
222			requireCode(t, tt.code, err)
223		})
224	}
225}
226
227func TestReleaseRPCDownload(t *testing.T) {
228	for _, selector := range []string{"id", "tag", "latest"} {
229		t.Run(selector, func(t *testing.T) {
230			rel := mocks.NewReleaseService(t)
231			c, _ := releaseClient(t, rel)
232			req := &pb.DownloadReleaseAssetRequest{Repo: "demo", Offset: 3}
233			ref := ports.AssetRef{}
234			switch selector {
235			case "id":
236				req.Selector = &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}
237				ref.ID = 9
238			case "tag":
239				req.Selector = &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1", Name: "a"}}
240				ref.Tag = "v1"
241				ref.Name = "a"
242			default:
243				req.Selector = &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Latest: true, Name: "a"}}
244				ref.Latest = true
245				ref.Name = "a"
246			}
247			a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "a", Size: 8}
248			rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ref, int64(3), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
249				if err := send(ports.AssetChunk{Asset: &a}); err != nil {
250					return err
251				}
252				if err := send(ports.AssetChunk{Data: []byte("12")}); err != nil {
253					return err
254				}
255				return send(ports.AssetChunk{Data: []byte("345")})
256			}).Once()
257			stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(req))
258			require.NoError(t, err)
259			defer func() { _ = stream.Close() }()
260			require.True(t, stream.Receive())
261			assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset))
262			assert.Empty(t, stream.Msg().Chunk)
263			var got []byte
264			for stream.Receive() {
265				assert.Nil(t, stream.Msg().Asset)
266				got = append(got, stream.Msg().Chunk...)
267			}
268			require.NoError(t, stream.Err())
269			assert.Equal(t, "12345", string(got))
270		})
271	}
272}
273
274func TestReleaseRPCErrors(t *testing.T) {
275	for _, tt := range []struct {
276		err  error
277		code connect.Code
278	}{{domain.ErrNotFound, connect.CodeNotFound}, {domain.ErrAlreadyExists, connect.CodeAlreadyExists}, {domain.ErrResourceExhausted, connect.CodeResourceExhausted}, {domain.ErrForbidden, connect.CodePermissionDenied}, {domain.ErrOutOfRange, connect.CodeOutOfRange}, {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition}, {domain.ErrInvalid, connect.CodeInvalidArgument}} {
279		t.Run(tt.code.String(), func(t *testing.T) {
280			rel := mocks.NewReleaseService(t)
281			c, _ := releaseClient(t, rel)
282			rel.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.ReleaseRef{ID: 7}).Return(domain.Release{}, tt.err).Once()
283			_, err := c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}}))
284			requireCode(t, tt.code, err)
285			rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).Return(tt.err).Once()
286			stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
287			if err == nil {
288				defer func() { _ = stream.Close() }()
289				assert.False(t, stream.Receive())
290				err = stream.Err()
291			}
292			requireCode(t, tt.code, err)
293		})
294	}
295}
296
297func TestReleaseMasksAndSelectors(t *testing.T) {
298	paths := []string{"tag", "target_commitish", "title", "notes", "draft", "prerelease", "make_latest"}
299	p, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
300	require.NoError(t, err)
301	assert.Equal(t, ports.UpdateReleaseParams{Tag: new(""), TargetCommitish: new(""), Title: new(""), Notes: new(""), Draft: new(false), Prerelease: new(false), MakeLatest: new(false)}, p)
302	for _, paths := range [][]string{nil, {"unknown"}, {"*"}, {"title", "assets"}} {
303		_, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
304		assert.ErrorIs(t, err, domain.ErrInvalid)
305		_, err = assetUpdateParams(&pb.UpdateReleaseAssetRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})
306		assert.ErrorIs(t, err, domain.ErrInvalid)
307	}
308	for _, req := range []*pb.GetReleaseRequest{{}, {Selector: &pb.GetReleaseRequest_Id{Id: -1}}, {Selector: &pb.GetReleaseRequest_Tag{}}, {Selector: &pb.GetReleaseRequest_Latest{Latest: false}}} {
309		_, err := releaseRef(req)
310		assert.ErrorIs(t, err, domain.ErrInvalid)
311	}
312	for _, req := range []*pb.DownloadReleaseAssetRequest{{}, {Selector: &pb.DownloadReleaseAssetRequest_AssetId{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1"}}}, {Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}} {
313		_, err := assetRef(req)
314		assert.ErrorIs(t, err, domain.ErrInvalid)
315	}
316}
317
318func TestReleaseEvents(t *testing.T) {
319	r := domain.Release{ID: 7, Repo: mustRepoName(t, "demo"), Tag: "v1", Draft: true, Prerelease: true}
320	at := time.Now().UTC()
321	events := []domain.Event{domain.ReleasePublished{Release: r, At: at}, domain.ReleaseUpdated{Release: r, At: at}, domain.ReleaseDeleted{Release: r, At: at}, domain.ReleaseUnpublished{Release: r, At: at}}
322	expected := []*pb.Event{
323		{Kind: &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
324		{Kind: &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
325		{Kind: &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: "demo", Id: 7, Tag: "v1"}}},
326		{Kind: &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}},
327	}
328	for i, ev := range events {
329		t.Run(ev.EventKind(), func(t *testing.T) {
330			expected[i].Id = "event"
331			expected[i].Time = timestamp(at)
332			assert.True(t, proto.Equal(expected[i], watchEventToPB(ports.WatchEvent{ID: "event", Event: ev})))
333		})
334	}
335}
336
337func TestReleaseResponseEnrichment(t *testing.T) {
338	repo := mustRepoName(t, "demo")
339	for _, tt := range []struct {
340		name                 string
341		latest               domain.Release
342		latestErr, assetsErr error
343		code                 connect.Code
344		wantLatest           bool
345	}{
346		{name: "draft without latest", latestErr: domain.ErrNotFound},
347		{name: "different latest", latest: domain.Release{ID: 8}},
348		{name: "current latest", latest: domain.Release{ID: 7}, wantLatest: true},
349		{name: "latest lookup fails", latestErr: domain.ErrUnavailable, code: connect.CodeUnavailable},
350		{name: "assets lookup fails", assetsErr: domain.ErrNotFound, code: connect.CodeNotFound},
351	} {
352		t.Run(tt.name, func(t *testing.T) {
353			rel := mocks.NewReleaseService(t)
354			rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{ID: 7}).Return(domain.Release{ID: 7, Repo: repo, Draft: true}, nil).Once()
355			rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{Latest: true}).Return(tt.latest, tt.latestErr).Once()
356			if tt.code != connect.CodeUnavailable {
357				rel.EXPECT().ListAssets(mock.Anything, domain.Owner(), repo, int64(7)).Return(nil, tt.assetsErr).Once()
358			}
359			res, err := (&releaseService{rel}).GetRelease(ownerCtx(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}}))
360			if tt.code != 0 {
361				requireCode(t, tt.code, err)
362				return
363			}
364			require.NoError(t, err)
365			assert.Equal(t, tt.wantLatest, res.Msg.Release.Latest)
366			assert.Empty(t, res.Msg.Release.Assets)
367			assert.Nil(t, res.Msg.Release.PublishedAt)
368		})
369	}
370}
371
372func TestReleaseRPCAuthenticatedCaller(t *testing.T) {
373	rel := mocks.NewReleaseService(t)
374	auth := mocks.NewAuthService(t)
375	auth.EXPECT().Authenticate(mock.Anything, "secret").Return(domain.Owner(), nil).Once()
376	rel.EXPECT().DeleteRelease(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(7)).Return(nil).Once()
377	srv := httptest.NewServer(New(Config{Releases: rel, Auth: auth, Sessions: mocks.NewSessionService(t)}).Handler())
378	defer srv.Close()
379	c := ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL)
380	req := connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7})
381	req.Header().Set("Authorization", "Bearer secret")
382	_, err := c.DeleteRelease(context.Background(), req)
383	require.NoError(t, err)
384}
385
386func TestReleaseRPCDownloadFullChunk(t *testing.T) {
387	rel := mocks.NewReleaseService(t)
388	c, _ := releaseClient(t, rel)
389	data := bytes.Repeat([]byte("x"), domain.CIArtifactChunk)
390	rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
391		if err := send(ports.AssetChunk{Asset: &domain.ReleaseAsset{ID: 9, Size: int64(len(data))}}); err != nil {
392			return err
393		}
394		return send(ports.AssetChunk{Data: data})
395	}).Once()
396	stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
397	require.NoError(t, err)
398	defer func() { _ = stream.Close() }()
399	require.True(t, stream.Receive())
400	require.NotNil(t, stream.Msg().Asset)
401	require.True(t, stream.Receive())
402	assert.Equal(t, data, stream.Msg().Chunk)
403	assert.False(t, stream.Receive())
404	require.NoError(t, stream.Err())
405}
406
407func TestReleaseRPCRejectsInvalidMasksAndSelectors(t *testing.T) {
408	c, _ := releaseClient(t, mocks.NewReleaseService(t))
409	_, err := c.UpdateRelease(context.Background(), connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"unknown"}}}))
410	requireCode(t, connect.CodeInvalidArgument, err)
411	_, err = c.UpdateReleaseAsset(context.Background(), connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo"}))
412	requireCode(t, connect.CodeInvalidArgument, err)
413	_, err = c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Latest{Latest: false}}))
414	requireCode(t, connect.CodeInvalidArgument, err)
415	stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}))
416	if err == nil {
417		defer func() { _ = stream.Close() }()
418		assert.False(t, stream.Receive())
419		err = stream.Err()
420	}
421	requireCode(t, connect.CodeInvalidArgument, err)
422}
423
424func TestReleaseRPCListResolvesLatestOnce(t *testing.T) {
425	repo := mustRepoName(t, "demo")
426	rel := mocks.NewReleaseService(t)
427	c, _ := releaseClient(t, rel)
428	p := domain.Anonymous()
429	rows := []domain.Release{{ID: 3, Repo: repo, Tag: "c"}, {ID: 2, Repo: repo, Tag: "b"}, {ID: 1, Repo: repo, Tag: "a"}}
430	rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: rows}, nil).Once()
431	rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(rows[1], nil).Once()
432	for _, r := range rows {
433		rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return(nil, nil).Once()
434	}
435	res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"}))
436	require.NoError(t, err)
437	require.Len(t, res.Msg.Releases, 3)
438	for _, m := range res.Msg.Releases {
439		assert.Equal(t, m.Id == 2, m.Latest, m.Tag)
440	}
441}
442
443func TestReleaseRPCListWithoutLatest(t *testing.T) {
444	repo := mustRepoName(t, "demo")
445	rel := mocks.NewReleaseService(t)
446	c, _ := releaseClient(t, rel)
447	p := domain.Anonymous()
448	rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: []domain.Release{{ID: 1, Repo: repo}}}, nil).Once()
449	rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound).Once()
450	rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(1)).Return(nil, nil).Once()
451	res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"}))
452	require.NoError(t, err)
453	require.Len(t, res.Msg.Releases, 1)
454	assert.False(t, res.Msg.Releases[0].Latest)
455}
456
457func TestReleaseRPCDownloadZeroByteAsset(t *testing.T) {
458	rel := mocks.NewReleaseService(t)
459	c, _ := releaseClient(t, rel)
460	a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "empty", Size: 0}
461	// The use case sends the metadata and no data for an empty asset.
462	rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
463		return send(ports.AssetChunk{Asset: &a})
464	}).Once()
465	stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}}))
466	require.NoError(t, err)
467	defer func() { _ = stream.Close() }()
468	require.True(t, stream.Receive())
469	assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset))
470	assert.False(t, stream.Receive())
471	require.NoError(t, stream.Err())
472}

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 3

Tags 44

↑↓ moveenter openesc close