v0.34.0 7c1886f

cli_release_test.go RawHistoryFolder

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}

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