1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "crypto/sha256"
8 "fmt"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "sync"
14 "testing"
15
16 "connectrpc.com/connect"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19 "google.golang.org/protobuf/proto"
20
21 "bnema.dev/ermite-api/gen/ermite"
22 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
23)
24
25// A focused version of cli_fake_test's Connect rig, without changing the shared fake.
26type fakeReleaseAPI struct {
27 ermiteconnect.UnimplementedReleaseServiceHandler
28 mu sync.Mutex
29 create *ermite.CreateReleaseRequest
30 gets []*ermite.GetReleaseRequest
31 lists []*ermite.ListReleasesRequest
32 update *ermite.UpdateReleaseRequest
33 deleted *ermite.DeleteReleaseRequest
34 uploads []*ermite.UploadReleaseAssetRequest
35 offsets []int64
36 release *ermite.Release
37 data []byte
38 uploadErr error
39 download func(*ermite.DownloadReleaseAssetRequest, *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error
40}
41
42func (f *fakeReleaseAPI) serve(t *testing.T, features ...string) string {
43 t.Helper()
44 mux := http.NewServeMux()
45 p, h := ermiteconnect.NewReleaseServiceHandler(f)
46 mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
47 v := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
48 return &ermite.GetVersionResponse{Features: features}, nil
49 }}
50 p, h = ermiteconnect.NewServerServiceHandler(v)
51 mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h))
52 srv := httptest.NewServer(mux)
53 t.Cleanup(srv.Close)
54 return srv.URL
55}
56func (f *fakeReleaseAPI) CreateRelease(_ context.Context, r *connect.Request[ermite.CreateReleaseRequest]) (*connect.Response[ermite.CreateReleaseResponse], error) {
57 f.mu.Lock()
58 defer f.mu.Unlock()
59 f.create = proto.Clone(r.Msg).(*ermite.CreateReleaseRequest)
60 return connect.NewResponse(&ermite.CreateReleaseResponse{Release: f.release}), nil
61}
62func (f *fakeReleaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) {
63 f.mu.Lock()
64 defer f.mu.Unlock()
65 f.gets = append(f.gets, proto.Clone(r.Msg).(*ermite.GetReleaseRequest))
66 return connect.NewResponse(&ermite.GetReleaseResponse{Release: f.release}), nil
67}
68func (f *fakeReleaseAPI) ListReleases(_ context.Context, r *connect.Request[ermite.ListReleasesRequest]) (*connect.Response[ermite.ListReleasesResponse], error) {
69 f.mu.Lock()
70 defer f.mu.Unlock()
71 f.lists = append(f.lists, proto.Clone(r.Msg).(*ermite.ListReleasesRequest))
72 next := ""
73 if r.Msg.GetPageToken() == "" {
74 next = "next"
75 }
76 return connect.NewResponse(&ermite.ListReleasesResponse{Releases: []*ermite.Release{f.release}, NextPageToken: next}), nil
77}
78func (f *fakeReleaseAPI) UpdateRelease(_ context.Context, r *connect.Request[ermite.UpdateReleaseRequest]) (*connect.Response[ermite.UpdateReleaseResponse], error) {
79 f.mu.Lock()
80 defer f.mu.Unlock()
81 f.update = proto.Clone(r.Msg).(*ermite.UpdateReleaseRequest)
82 return connect.NewResponse(&ermite.UpdateReleaseResponse{Release: f.release}), nil
83}
84func (f *fakeReleaseAPI) DeleteRelease(_ context.Context, r *connect.Request[ermite.DeleteReleaseRequest]) (*connect.Response[ermite.DeleteReleaseResponse], error) {
85 f.mu.Lock()
86 defer f.mu.Unlock()
87 f.deleted = proto.Clone(r.Msg).(*ermite.DeleteReleaseRequest)
88 return connect.NewResponse(&ermite.DeleteReleaseResponse{}), nil
89}
90func (f *fakeReleaseAPI) UploadReleaseAsset(_ context.Context, st *connect.ClientStream[ermite.UploadReleaseAssetRequest]) (*connect.Response[ermite.UploadReleaseAssetResponse], error) {
91 var got []*ermite.UploadReleaseAssetRequest
92 for st.Receive() {
93 got = append(got, proto.Clone(st.Msg()).(*ermite.UploadReleaseAssetRequest))
94 }
95 if err := st.Err(); err != nil {
96 return nil, err
97 }
98 f.mu.Lock()
99 defer f.mu.Unlock()
100 f.uploads = append(f.uploads, got...)
101 if f.uploadErr != nil {
102 return nil, f.uploadErr
103 }
104 return connect.NewResponse(&ermite.UploadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: got[0].GetName(), Size: got[0].GetSize()}}), nil
105}
106func (f *fakeReleaseAPI) DownloadReleaseAsset(_ context.Context, r *connect.Request[ermite.DownloadReleaseAssetRequest], st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
107 f.mu.Lock()
108 f.offsets = append(f.offsets, r.Msg.GetOffset())
109 fn := f.download
110 f.mu.Unlock()
111 if fn != nil {
112 return fn(r.Msg, st)
113 }
114 if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: f.release.Assets[0]}); err != nil {
115 return err
116 }
117 return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: f.data[r.Msg.GetOffset():]})
118}
119func releaseFixture() *ermite.Release {
120 return &ermite.Release{Id: 7, Repo: "proj", Tag: "v1", Title: "First", Notes: "# Notes\n", PublishedAt: ts("2026-10-10T12:00:00Z"), Assets: []*ermite.ReleaseAsset{}}
121}
122
123func TestCLIReleaseCreateAndUpload(t *testing.T) {
124 data := make([]byte, 5<<19)
125 file := filepath.Join(t.TempDir(), "asset.bin")
126 require.NoError(t, os.WriteFile(file, data, 0o600))
127 notes := filepath.Join(t.TempDir(), "notes.md")
128 require.NoError(t, os.WriteFile(notes, []byte("## hello"), 0o600))
129 f := &fakeReleaseAPI{release: releaseFixture()}
130 vars := envFor(f.serve(t, "releases"))
131 res := runCLI(t, vars, "", "release", "create", "proj", "v1", "--title", "Hello", "--notes-file", notes, "--target", "main", "--draft", "--prerelease", "--latest", "--asset", file+"#Binary", "--json").ok(t)
132 out := decode[jsonRelease](t, res.stdout)
133 assert.Equal(t, int64(7), out.ID)
134 require.Len(t, out.Assets, 1)
135 f.mu.Lock()
136 defer f.mu.Unlock()
137 assert.True(t, proto.Equal(&ermite.CreateReleaseRequest{Repo: "proj", Tag: "v1", Title: "Hello", Notes: "## hello", TargetCommitish: "main", Draft: true, Prerelease: true, MakeLatest: true}, f.create))
138 require.Len(t, f.uploads, 4)
139 assert.Equal(t, "Binary", f.uploads[0].GetLabel())
140 assert.Equal(t, int64(len(data)), f.uploads[0].GetSize())
141 assert.Empty(t, f.uploads[0].GetData())
142 assert.False(t, f.uploads[0].GetReplace())
143 assert.NotEmpty(t, f.uploads[0].GetContentType())
144 for i, n := range []int{1 << 20, 1 << 20, 1 << 19} {
145 assert.Len(t, f.uploads[i+1].GetData(), n)
146 assert.Empty(t, f.uploads[i+1].GetRepo())
147 assert.Empty(t, f.uploads[i+1].GetName())
148 }
149}
150func TestCLIReleaseUpload(t *testing.T) {
151 file := filepath.Join(t.TempDir(), "x")
152 require.NoError(t, os.WriteFile(file, []byte("hello"), 0o600))
153 f := &fakeReleaseAPI{release: releaseFixture()}
154 vars := envFor(f.serve(t, "releases"))
155 runCLI(t, vars, "", "release", "upload", "proj", "7", file+"#A", "--clobber", "--content-type", "application/test").ok(t)
156 f.mu.Lock()
157 assert.True(t, f.uploads[0].GetReplace())
158 assert.Equal(t, "application/test", f.uploads[0].GetContentType())
159 assert.Equal(t, int64(7), f.gets[0].GetId())
160 f.uploadErr = connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("taken"))
161 f.mu.Unlock()
162 res := runCLI(t, vars, "", "release", "upload", "proj", "v1", file)
163 require.ErrorContains(t, res.err, "--clobber")
164 assert.Contains(t, res.err.Error(), `"x"`)
165}
166func TestCLIReleaseList(t *testing.T) {
167 f := &fakeReleaseAPI{release: releaseFixture()}
168 vars := envFor(f.serve(t, "releases"))
169 res := runCLI(t, vars, "", "release", "list", "proj", "--limit", "1").ok(t)
170 assert.Equal(t, "ID TAG TITLE STATE PUBLISHED ASSETS\n7 v1 First published 2026-10-10 12:00 0\n", res.stdout)
171 res = runCLI(t, vars, "", "release", "list", "proj", "--limit", "2", "--json").ok(t)
172 assert.Len(t, decode[[]jsonRelease](t, res.stdout), 2)
173 f.mu.Lock()
174 defer f.mu.Unlock()
175 require.Len(t, f.lists, 3)
176 assert.Equal(t, int32(2), f.lists[1].GetPageSize())
177 assert.Equal(t, "next", f.lists[2].GetPageToken())
178 assert.Equal(t, int32(1), f.lists[2].GetPageSize())
179}
180func TestCLIReleaseViewSelectors(t *testing.T) {
181 f := &fakeReleaseAPI{release: releaseFixture()}
182 vars := envFor(f.serve(t, "releases"))
183 for _, sel := range []string{"v1", "7", "latest"} {
184 runCLI(t, vars, "", "release", "view", "proj", sel, "--json").ok(t)
185 }
186 assert.Equal(t, "# Notes\n", runCLI(t, vars, "", "release", "view", "proj", "latest", "--notes").ok(t).stdout)
187 res := runCLI(t, vars, "", "release", "view", "proj", "v1").ok(t)
188 assert.Contains(t, res.stdout, "NAME SIZE TYPE")
189 assert.Contains(t, res.stdout, "First")
190 f.mu.Lock()
191 defer f.mu.Unlock()
192 assert.Equal(t, "v1", f.gets[0].GetTag())
193 assert.Equal(t, int64(7), f.gets[1].GetId())
194 assert.True(t, f.gets[2].GetLatest())
195}
196func TestCLIReleaseEdit(t *testing.T) {
197 f := &fakeReleaseAPI{release: releaseFixture()}
198 vars := envFor(f.serve(t, "releases"))
199 runCLI(t, vars, "", "release", "edit", "proj", "v1", "--title=", "--notes-file", "-", "--tag", "v2", "--target", "main", "--publish", "--no-prerelease", "--latest=false").ok(t)
200 f.mu.Lock()
201 assert.ElementsMatch(t, []string{"title", "notes", "tag", "target_commitish", "draft", "prerelease", "make_latest"}, f.update.GetUpdateMask().GetPaths())
202 assert.False(t, f.update.GetDraft())
203 assert.False(t, f.update.GetPrerelease())
204 assert.False(t, f.update.GetMakeLatest())
205 assert.Empty(t, f.update.GetTitle())
206 assert.Equal(t, "v2", f.update.GetTag())
207 f.mu.Unlock()
208 runCLI(t, vars, "", "release", "edit", "proj", "7", "--draft", "--prerelease", "--notes", "text").ok(t)
209 f.mu.Lock()
210 defer f.mu.Unlock()
211 assert.True(t, f.update.GetDraft())
212 assert.True(t, f.update.GetPrerelease())
213 assert.Equal(t, "text", f.update.GetNotes())
214}
215func TestCLIReleaseDelete(t *testing.T) {
216 for _, flags := range [][]string{nil, {"--yes"}} {
217 f := &fakeReleaseAPI{release: releaseFixture()}
218 vars := envFor(f.serve(t, "releases"))
219 res := runCLI(t, vars, "", append([]string{"release", "delete", "proj", "v1"}, flags...)...).ok(t)
220 assert.Equal(t, "deleted release 7\n", res.stdout)
221 f.mu.Lock()
222 assert.Equal(t, int64(7), f.deleted.GetId())
223 assert.Equal(t, "proj", f.deleted.GetRepo())
224 f.mu.Unlock()
225 }
226}
227func TestCLIReleaseNoFeature(t *testing.T) {
228 f := &fakeReleaseAPI{release: releaseFixture()}
229 vars := envFor(f.serve(t))
230 for _, args := range [][]string{{"create", "proj", "v1"}, {"list", "proj"}, {"view", "proj", "latest"}, {"edit", "proj", "v1", "--title", "x"}, {"delete", "proj", "v1"}, {"upload", "proj", "v1", "x"}, {"download", "proj", "latest"}} {
231 res := runCLI(t, vars, "", append([]string{"release"}, args...)...)
232 require.ErrorIs(t, res.err, errNoReleases)
233 }
234 f.mu.Lock()
235 defer f.mu.Unlock()
236 assert.Nil(t, f.create)
237 assert.Empty(t, f.gets)
238 assert.Nil(t, f.update)
239 assert.Nil(t, f.deleted)
240 assert.Empty(t, f.uploads)
241}
242func TestCLIReleaseFlagValidation(t *testing.T) {
243 for _, args := range [][]string{{"create", "proj", "v1", "--notes", "x", "--notes-file", "f"}, {"edit", "proj", "v1", "--draft", "--publish"}, {"edit", "proj", "v1", "--prerelease", "--no-prerelease"}, {"edit", "proj", "v1"}, {"list", "proj", "--limit", "0"}, {"download", "proj", "v1", "--pattern", "["}, {"view", "proj", "v1", "--notes", "--json"}} {
244 require.Error(t, runCLI(t, nil, "", append([]string{"release"}, args...)...).err)
245 }
246}
247func TestCLIReleaseDownload(t *testing.T) {
248 for _, mode := range []string{"normal", "resume", "twice broken", "digest mismatch", "unsafe", "overwrite", "empty"} {
249 t.Run(mode, func(t *testing.T) {
250 data := []byte("hello world")
251 if mode == "empty" {
252 data = nil
253 }
254 a := &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: "asset.bin", Size: int64(len(data)), Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}
255 if mode == "unsafe" {
256 a.Name = "../escape"
257 }
258 if mode == "digest mismatch" {
259 a.Digest = "sha256:bad"
260 }
261 r := releaseFixture()
262 r.Assets = []*ermite.ReleaseAsset{a}
263 f := &fakeReleaseAPI{release: r, data: data}
264 if mode == "resume" || mode == "twice broken" {
265 f.download = func(req *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
266 if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil {
267 return err
268 }
269 if req.GetOffset() == 0 {
270 if err := st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[:4]}); err != nil {
271 return err
272 }
273 return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken"))
274 }
275 if mode == "twice broken" {
276 return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken"))
277 }
278 return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[req.GetOffset():]})
279 }
280 }
281 dir := t.TempDir()
282 path := filepath.Join(dir, a.GetName())
283 if mode == "overwrite" {
284 require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
285 }
286 res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "latest", "--dir", dir)
287 switch mode {
288 case "unsafe", "twice broken", "digest mismatch":
289 require.Error(t, res.err)
290 if mode != "unsafe" {
291 _, err := os.Stat(path)
292 assert.True(t, os.IsNotExist(err))
293 }
294 case "overwrite":
295 require.Error(t, res.err)
296 got, err := os.ReadFile(path)
297 require.NoError(t, err)
298 assert.Equal(t, "keep", string(got))
299 default:
300 res.ok(t)
301 got, err := os.ReadFile(path)
302 require.NoError(t, err)
303 assert.Equal(t, string(data), string(got))
304 }
305 f.mu.Lock()
306 defer f.mu.Unlock()
307 if mode == "resume" || mode == "twice broken" {
308 assert.Equal(t, []int64{0, 4}, f.offsets)
309 }
310 if mode == "normal" {
311 assert.True(t, f.gets[0].GetLatest())
312 }
313 })
314 }
315}
316func TestReleaseAssetSelection(t *testing.T) {
317 all := []*ermite.ReleaseAsset{{Name: "a.zip"}, {Name: "b.txt"}, {Name: "c.zip"}}
318 picked, err := pickReleaseAssets(all, []string{"b.txt"}, []string{"*.zip"})
319 require.NoError(t, err)
320 assert.Len(t, picked, 3)
321 picked, err = pickReleaseAssets(all, nil, []string{"*.zip"})
322 require.NoError(t, err)
323 assert.Len(t, picked, 2)
324 _, err = pickReleaseAssets(all, []string{"missing"}, nil)
325 require.Error(t, err)
326}
327
328func TestCLIReleaseDownloadProtocolFailures(t *testing.T) {
329 for _, mode := range []string{"missing metadata", "wrong metadata", "too much data", "short data", "permission denied"} {
330 t.Run(mode, func(t *testing.T) {
331 data := []byte("abc")
332 a := &ermite.ReleaseAsset{Id: 9, Name: "a", Size: 3, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}
333 r := releaseFixture()
334 r.Assets = []*ermite.ReleaseAsset{a}
335 f := &fakeReleaseAPI{release: r, download: func(_ *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error {
336 switch mode {
337 case "missing metadata":
338 return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data})
339 case "wrong metadata":
340 return st.Send(&ermite.DownloadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 10}})
341 case "permission denied":
342 return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("denied"))
343 }
344 if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil {
345 return err
346 }
347 if mode == "too much data" {
348 return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("abcd")})
349 }
350 return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("a")})
351 }}
352 dir := t.TempDir()
353 res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "v1", "--dir", dir)
354 require.Error(t, res.err)
355 _, err := os.Stat(filepath.Join(dir, "a"))
356 assert.True(t, os.IsNotExist(err))
357 f.mu.Lock()
358 defer f.mu.Unlock()
359 if mode == "short data" {
360 assert.Len(t, f.offsets, 2)
361 } else {
362 assert.Len(t, f.offsets, 1)
363 }
364 })
365 }
366}
367
368func TestCLIReleaseUploadContentTypeAndEmpty(t *testing.T) {
369 for _, data := range []string{"hello world", ""} {
370 t.Run(fmt.Sprintf("size %d", len(data)), func(t *testing.T) {
371 file := filepath.Join(t.TempDir(), "unknown-extension")
372 require.NoError(t, os.WriteFile(file, []byte(data), 0o600))
373 f := &fakeReleaseAPI{release: releaseFixture()}
374 vars := envFor(f.serve(t, "releases"))
375 runCLI(t, vars, "", "release", "upload", "proj", "v1", file).ok(t)
376 f.mu.Lock()
377 defer f.mu.Unlock()
378 assert.Equal(t, int64(len(data)), f.uploads[0].GetSize())
379 assert.Equal(t, "text/plain; charset=utf-8", f.uploads[0].GetContentType())
380 if data == "" {
381 assert.Len(t, f.uploads, 1)
382 } else {
383 require.Len(t, f.uploads, 2)
384 assert.Equal(t, data, string(f.uploads[1].GetData()))
385 }
386 })
387 }
388}