releases_github_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"bytes"
7	"encoding/json"
8	"fmt"
9	"io"
10	"math"
11	"net/http"
12	"net/http/httptest"
13	"os"
14	"path/filepath"
15	"strconv"
16	"strings"
17	"testing"
18
19	"bnema.dev/ermite/internal/adapters/githubapi"
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22	"bnema.dev/ermite/internal/ports/mocks"
23	"bnema.dev/ermite/internal/usecase"
24	"github.com/stretchr/testify/mock"
25	"github.com/stretchr/testify/require"
26)
27
28func releaseGitHub(t *testing.T, r *releaseRig) *usecase.ReleasesGitHub {
29	t.Helper()
30	return usecase.NewReleasesGitHub(r.svc, "acme", "http://localhost:7341/")
31}
32func releaseGolden(t *testing.T, file string, body []byte) {
33	t.Helper()
34	var pretty bytes.Buffer
35	require.NoError(t, json.Indent(&pretty, body, "", "  "))
36	pretty.WriteByte('\n')
37	path := filepath.Join("..", "adapters", "githubapi", "testdata", "releases", file+".json")
38	if os.Getenv("UPDATE_RELEASE_GOLDENS") == "1" {
39		require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
40		require.NoError(t, os.WriteFile(path, pretty.Bytes(), 0644))
41	}
42	want, err := os.ReadFile(path)
43	require.NoError(t, err)
44	require.Equal(t, string(want), pretty.String())
45}
46func TestGitHubReleaseGoldens(t *testing.T) {
47	r := newReleaseRig(t, ports.ReleaseLimits{})
48	g := releaseGitHub(t, r)
49	rel := r.create(t, "v1", false)
50	for _, name := range []string{"ermite.tar.gz", "checksums.txt"} {
51		_, err := g.UploadAsset(r.ctx, domain.Root(), "acme", r.repo.String(), rel.ID, name, "", "application/octet-stream", 3, strings.NewReader("abc"))
52		require.NoError(t, err)
53	}
54	body, err := g.GetRelease(r.ctx, domain.Anonymous(), "ACME", "RELEASES", ports.ReleaseRef{Tag: "v1"})
55	require.NoError(t, err)
56	releaseGolden(t, "release", body)
57	draft := r.create(t, "next", true)
58	body, err = g.GetRelease(r.ctx, domain.Root(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID})
59	require.NoError(t, err)
60	releaseGolden(t, "draft", body)
61	_, err = g.GetRelease(r.ctx, domain.Anonymous(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID})
62	require.ErrorIs(t, err, domain.ErrNotFound)
63}
64func TestGitHubReleaseRequests(t *testing.T) {
65	r := newReleaseRig(t, ports.ReleaseLimits{})
66	g := releaseGitHub(t, r)
67	repo := r.repo.String()
68	p := domain.Root()
69	r.resolve(t, "v1", nil)
70	body, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1","name":"Title","body":"Notes","draft":true,"prerelease":true,"discussion_category_name":"ignored","make_latest":"legacy"}`))
71	require.NoError(t, err)
72	var rel struct {
73		ID int64 `json:"id"`
74	}
75	require.NoError(t, json.Unmarshal(body, &rel))
76	body, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"name":"","draft":false,"prerelease":false,"make_latest":"true","unknown":1}`))
77	require.NoError(t, err)
78	var got map[string]any
79	require.NoError(t, json.Unmarshal(body, &got))
80	require.Equal(t, "", got["name"])
81	require.Equal(t, "Notes", got["body"])
82	require.Equal(t, false, got["draft"])
83	require.Equal(t, false, got["prerelease"])
84	require.NotContains(t, got, "make_latest")
85	stored, err := r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
86	require.NoError(t, err)
87	require.True(t, stored.MakeLatest)
88	_, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"legacy"}`))
89	require.NoError(t, err)
90	stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
91	require.NoError(t, err)
92	require.True(t, stored.MakeLatest)
93	_, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"false"}`))
94	require.NoError(t, err)
95	stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
96	require.NoError(t, err)
97	require.False(t, stored.MakeLatest)
98	for _, bad := range []string{`{}`, `null`, `[]`, `{`, `{"tag_name":2}`, `{"tag_name":"v1","draft":"true"}`, `{"tag_name":"v1","name":null}`, `{"tag_name":"v1","make_latest":true}`, `{"tag_name":"v1","make_latest":"yes"}`} {
99		_, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(bad))
100		require.ErrorIs(t, err, domain.ErrInvalid, bad)
101	}
102	_, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1"}`))
103	require.ErrorIs(t, err, domain.ErrAlreadyExists)
104	_, err = g.GetRelease(r.ctx, p, "other", repo, ports.ReleaseRef{ID: rel.ID})
105	require.ErrorIs(t, err, domain.ErrNotFound)
106	_, err = g.GetRelease(r.ctx, p, "acme", "bad/name", ports.ReleaseRef{ID: rel.ID})
107	require.ErrorIs(t, err, domain.ErrNotFound)
108}
109func TestGitHubReleasePagination(t *testing.T) {
110	r := newReleaseRig(t, ports.ReleaseLimits{})
111	g := releaseGitHub(t, r)
112	for i := 0; i < 5; i++ {
113		r.create(t, "v"+strconv.Itoa(i), i == 4)
114	}
115	for page := 1; page <= 3; page++ {
116		b, link, err := g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2)
117		require.NoError(t, err)
118		var rows []struct {
119			ID int64 `json:"id"`
120		}
121		require.NoError(t, json.Unmarshal(b, &rows))
122		require.Len(t, rows, min(2, 5-(page-1)*2))
123		require.Equal(t, int64(5-(page-1)*2), rows[0].ID)
124		base := "http://localhost:7341/-/github/api/repos/acme/releases/releases?per_page=2&page="
125		switch page {
126		case 1:
127			require.Equal(t, `<`+base+`2>; rel="next", <`+base+`3>; rel="last"`, link)
128		case 2:
129			require.Equal(t, `<`+base+`3>; rel="next", <`+base+`3>; rel="last", <`+base+`1>; rel="first", <`+base+`1>; rel="prev"`, link)
130		case 3:
131			require.Equal(t, `<`+base+`1>; rel="first", <`+base+`2>; rel="prev"`, link)
132		}
133	}
134	b, _, err := g.ListReleases(r.ctx, domain.Anonymous(), "acme", r.repo.String(), 0, 0)
135	require.NoError(t, err)
136	var rows []any
137	require.NoError(t, json.Unmarshal(b, &rows))
138	require.Len(t, rows, 4)
139	for _, page := range []int{4, math.MaxInt} {
140		b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2)
141		require.NoError(t, err)
142		require.Equal(t, "[]", string(b))
143	}
144	b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), 1, 1000)
145	require.NoError(t, err)
146	require.NoError(t, json.Unmarshal(b, &rows))
147	require.Len(t, rows, 5)
148}
149func TestGitHubReleaseAssetsAndRetry(t *testing.T) {
150	r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10})
151	g := releaseGitHub(t, r)
152	p := domain.Root()
153	repo := r.repo.String()
154	rel := r.create(t, "v1/slash", false)
155	upload := func(name string) ([]byte, error) {
156		return g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, name, "label", "text/plain", 3, strings.NewReader("abc"))
157	}
158	b, err := upload("é #?.txt")
159	require.NoError(t, err)
160	var asset struct {
161		ID  int64  `json:"id"`
162		URL string `json:"browser_download_url"`
163	}
164	require.NoError(t, json.Unmarshal(b, &asset))
165	require.Equal(t, "http://localhost:7341/releases/releases/download/v1%2Fslash/%C3%A9%20%23%3F.txt", asset.URL)
166	_, err = upload("é #?.txt")
167	require.ErrorIs(t, err, domain.ErrAlreadyExists)
168	b, err = g.UpdateAsset(r.ctx, p, "acme", repo, asset.ID, []byte(`{"label":""}`))
169	require.NoError(t, err)
170	require.Contains(t, string(b), `"label":""`)
171	require.Contains(t, string(b), `"name":"é #?.txt"`)
172	b, err = g.GetAsset(r.ctx, domain.Anonymous(), "acme", repo, asset.ID)
173	require.NoError(t, err)
174	require.Contains(t, string(b), `"state":"uploaded"`)
175	for i := 0; i < 2; i++ {
176		_, err = upload(fmt.Sprint(i))
177		require.NoError(t, err)
178	}
179	b, link, err := g.ListAssets(r.ctx, p, "acme", repo, rel.ID, 1, 2)
180	require.NoError(t, err)
181	require.Contains(t, link, `rel="next"`)
182	var assets []any
183	require.NoError(t, json.Unmarshal(b, &assets))
184	require.Len(t, assets, 2)
185	require.NoError(t, g.DeleteAsset(r.ctx, p, "acme", repo, asset.ID))
186	_, err = g.AssetByID(r.ctx, p, "acme", repo, asset.ID)
187	require.ErrorIs(t, err, domain.ErrNotFound)
188	_, err = upload("é #?.txt")
189	require.NoError(t, err)
190	_, err = g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, "huge", "", "", 11, strings.NewReader("ignored"))
191	require.ErrorIs(t, err, domain.ErrResourceExhausted)
192	require.NoError(t, g.DeleteRelease(r.ctx, p, "acme", repo, rel.ID))
193	_, err = g.GetRelease(r.ctx, p, "acme", repo, ports.ReleaseRef{ID: rel.ID})
194	require.ErrorIs(t, err, domain.ErrNotFound)
195}
196
197func TestGitHubReleaseAuthorizationAndLatest(t *testing.T) {
198	r := newReleaseRig(t, ports.ReleaseLimits{})
199	g := releaseGitHub(t, r)
200	repo := r.repo.String()
201	stable := r.create(t, "v1", false)
202	r.create(t, "draft", true)
203	b, err := g.GetRelease(r.ctx, domain.Anonymous(), "acme", repo, ports.ReleaseRef{Latest: true})
204	require.NoError(t, err)
205	var got struct {
206		ID int64 `json:"id"`
207	}
208	require.NoError(t, json.Unmarshal(b, &got))
209	require.Equal(t, stable.ID, got.ID)
210	writer := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionWrite}})
211	reader := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
212	branched := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, BranchPrefixes: []string{"feature/"}})
213	for _, tc := range []struct {
214		p    domain.Principal
215		tag  string
216		want error
217	}{
218		{domain.Anonymous(), "anonymous", domain.ErrUnauthenticated},
219		{reader, "reader", domain.ErrForbidden},
220		{branched, "branch", domain.ErrForbidden},
221		{writer, "job", nil},
222	} {
223		if tc.want == nil {
224			r.resolve(t, tc.tag, nil)
225		}
226		b, err = g.CreateRelease(r.ctx, tc.p, "acme", repo, []byte(`{"tag_name":"`+tc.tag+`"}`))
227		if tc.want != nil {
228			require.ErrorIs(t, err, tc.want)
229			continue
230		}
231		require.NoError(t, err)
232		require.NoError(t, json.Unmarshal(b, &got))
233		_, err = g.UploadAsset(r.ctx, tc.p, "acme", repo, got.ID, "ci.tar.gz", "", "", 3, strings.NewReader("abc"))
234		require.NoError(t, err)
235		_, err = g.UploadAsset(r.ctx, reader, "acme", repo, got.ID, "read.tar.gz", "", "", 3, strings.NewReader("abc"))
236		require.ErrorIs(t, err, domain.ErrForbidden)
237	}
238	other, err := domain.ParseRepoName("other")
239	require.NoError(t, err)
240	require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now}))
241	b, _, err = g.ListAssets(r.ctx, domain.Root(), "acme", other.String(), stable.ID, 1, 30)
242	require.ErrorIs(t, err, domain.ErrNotFound)
243	require.Nil(t, b)
244}
245
246func TestGitHubReleaseTargetAndMissingTag(t *testing.T) {
247	r := newReleaseRig(t, ports.ReleaseLimits{})
248	g := releaseGitHub(t, r)
249	p := domain.Root()
250	repo := r.repo.String()
251	r.resolve(t, "missing", domain.ErrNotFound)
252	_, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing"}`))
253	require.ErrorIs(t, err, domain.ErrPreconditionFailed)
254	b, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing","draft":true}`))
255	require.NoError(t, err)
256	var got struct {
257		ID     int64  `json:"id"`
258		Target string `json:"target_commitish"`
259	}
260	require.NoError(t, json.Unmarshal(b, &got))
261	require.Empty(t, got.Target)
262	_, err = g.UpdateRelease(r.ctx, p, "acme", repo, got.ID, []byte(`{"draft":false}`))
263	require.ErrorIs(t, err, domain.ErrPreconditionFailed)
264	r.resolve(t, "new", domain.ErrNotFound)
265	v, err := domain.ParseRevision("main")
266	require.NoError(t, err)
267	oid, err := domain.ParseObjectID(oidA)
268	require.NoError(t, err)
269	r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
270	r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once()
271	r.pushes.EXPECT().Record(r.ctx, p, r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
272	b, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"new","target_commitish":"main"}`))
273	require.NoError(t, err)
274	require.NoError(t, json.Unmarshal(b, &got))
275	require.Equal(t, oidA, got.Target)
276
277	// PATCH with target_commitish publishes a draft whose tag is missing.
278	r.resolve(t, "patched", domain.ErrNotFound)
279	b, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"patched","draft":true}`))
280	require.NoError(t, err)
281	require.NoError(t, json.Unmarshal(b, &got))
282	r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
283	r.tags.EXPECT().CreateTag(r.ctx, r.repo, "patched", oid).Return(nil).Once()
284	r.pushes.EXPECT().Record(r.ctx, p, r.repo, []domain.RefUpdate{{Ref: "refs/tags/patched", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
285	b, err = g.UpdateRelease(r.ctx, p, "acme", repo, got.ID, []byte(`{"draft":false,"target_commitish":"main"}`))
286	require.NoError(t, err)
287	require.Contains(t, string(b), `"draft":false`)
288	require.Contains(t, string(b), `"target_commitish":"`+oidA+`"`)
289}
290
291// A real HTTP round trip through the facade, metadata store and disk blob
292// adapter protects the goreleaser/softprops create, retry, overwrite, publish
293// sequence, not just each side's mocked contract.
294func TestGitHubReleaseHTTPWorkflow(t *testing.T) {
295	r := newReleaseRig(t, ports.ReleaseLimits{})
296	g := releaseGitHub(t, r)
297	auth := mocks.NewAuthService(t)
298	auth.EXPECT().Authenticate(mock.Anything, "dev-token").Return(domain.Root(), nil).Maybe()
299	srv := httptest.NewServer(githubapi.New(githubapi.Config{Auth: auth, Releases: g, ReleaseDownload: r.svc}))
300	defer srv.Close()
301	do := func(method, path, body string, headers map[string]string) (*http.Response, string) {
302		req, err := http.NewRequestWithContext(r.ctx, method, srv.URL+githubapi.Prefix+path, strings.NewReader(body))
303		require.NoError(t, err)
304		req.Header.Set("Authorization", "Bearer dev-token")
305		for k, v := range headers {
306			req.Header.Set(k, v)
307		}
308		resp, err := srv.Client().Do(req)
309		require.NoError(t, err)
310		defer func() { _ = resp.Body.Close() }()
311		b, err := io.ReadAll(resp.Body)
312		require.NoError(t, err)
313		return resp, string(b)
314	}
315	base := "/repos/acme/" + r.repo.String() + "/releases"
316	resp, _ := do("GET", base+"/tags/v1", "", nil)
317	require.Equal(t, 404, resp.StatusCode)
318	r.resolve(t, "v1", nil)
319	resp, b := do("POST", base, `{"tag_name":"v1","draft":true}`, nil)
320	require.Equal(t, 201, resp.StatusCode)
321	var rel struct {
322		ID        int64  `json:"id"`
323		UploadURL string `json:"upload_url"`
324	}
325	require.NoError(t, json.Unmarshal([]byte(b), &rel))
326	upload := strings.TrimPrefix(strings.Split(rel.UploadURL, "{")[0], "http://localhost:7341"+githubapi.Prefix) + "?name=archive.tar.gz"
327	resp, b = do("POST", upload, "0123456789", map[string]string{"Content-Type": "application/gzip"})
328	require.Equal(t, 201, resp.StatusCode)
329	var a struct {
330		ID int64 `json:"id"`
331	}
332	require.NoError(t, json.Unmarshal([]byte(b), &a))
333	resp, b = do("POST", upload, "replacement", nil)
334	require.Equal(t, 422, resp.StatusCode)
335	require.Contains(t, b, `"code":"already_exists"`)
336	resp, b = do("GET", base+"/"+strconv.FormatInt(rel.ID, 10)+"/assets", "", nil)
337	require.Equal(t, 200, resp.StatusCode)
338	require.Contains(t, b, `"name":"archive.tar.gz"`)
339	assetURL := base + "/assets/" + strconv.FormatInt(a.ID, 10)
340	resp, _ = do("DELETE", assetURL, "", nil)
341	require.Equal(t, 204, resp.StatusCode)
342	resp, b = do("POST", upload, "abcdefghij", nil)
343	require.Equal(t, 201, resp.StatusCode)
344	require.NoError(t, json.Unmarshal([]byte(b), &a))
345	resp, _ = do("PATCH", base+"/"+strconv.FormatInt(rel.ID, 10), `{"draft":false}`, nil)
346	require.Equal(t, 200, resp.StatusCode)
347	assetURL = base + "/assets/" + strconv.FormatInt(a.ID, 10)
348	resp, b = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5"})
349	require.Equal(t, 206, resp.StatusCode)
350	require.Equal(t, "cdef", b)
351	require.Equal(t, "bytes 2-5/10", resp.Header.Get("Content-Range"))
352	require.Equal(t, "4", resp.Header.Get("Content-Length"))
353	require.Empty(t, resp.Header.Get("Location"))
354	resp, b = do("HEAD", assetURL, "", map[string]string{"Accept": "application/octet-stream"})
355	require.Equal(t, 200, resp.StatusCode)
356	require.Empty(t, b)
357	require.Equal(t, "10", resp.Header.Get("Content-Length"))
358	resp, _ = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "If-None-Match": resp.Header.Get("ETag")})
359	require.Equal(t, 304, resp.StatusCode)
360}

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 4

Tags 45

↑↓ moveenter openesc close