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}