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 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}