feat(githubapi): GitHub-compatible REST API for releases and assets
- commit
- f67dc6a65ee597f5eb612c0f943afc21ef864279
- author
- brice · · 2026-10-21 15:13 UTC
- committer
- brice · · 2026-10-21 17:53 UTC
- parent
- eab40ee
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +2818 -2
M internal/adapters/githubapi/handler.go +18 -2
| @@ -68,6 +68,10 @@ const ( | |||
| 68 | 68 | type Config struct { | |
| 69 | 69 | Auth ports.AuthService | |
| 70 | 70 | API ports.GitHubAPIService | |
| 71 | + | // Releases and ReleaseDownload enable the release REST routes. Nil services | |
| 72 | + | // leave their routes unavailable with GitHub's 404 JSON. | |
| 73 | + | Releases ports.GitHubReleaseAPI | |
| 74 | + | ReleaseDownload ports.ReleaseService | |
| 71 | 75 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 72 | 76 | TrustedProxies []netip.Prefix | |
| 73 | 77 | // Limiter counts failed authentications; nil builds a default one. | |
| @@ -124,8 +128,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 124 | 128 | notFound(w) | |
| 125 | 129 | return | |
| 126 | 130 | } | |
| 127 | - | if r.Method != http.MethodGet && r.Method != http.MethodHead { | |
| 128 | - | w.Header().Set("Allow", "GET, HEAD") | |
| 131 | + | if strings.HasPrefix(kind, "release") && h.cfg.Releases == nil { | |
| 132 | + | notFound(w) | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | allowed := releaseMethods(kind) | |
| 136 | + | if !methodAllowed(r.Method, allowed) { | |
| 137 | + | w.Header().Set("Allow", allowed) | |
| 129 | 138 | jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed") | |
| 130 | 139 | return | |
| 131 | 140 | } | |
| @@ -148,6 +157,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 148 | 157 | return | |
| 149 | 158 | } | |
| 150 | 159 | ||
| 160 | + | if strings.HasPrefix(kind, "release") { | |
| 161 | + | h.releaseRequest(w, r, p, kind, owner, repo, ref, clientlimit.Key(addr)) | |
| 162 | + | return | |
| 163 | + | } | |
| 151 | 164 | switch kind { | |
| 152 | 165 | case "repo": | |
| 153 | 166 | body, err := h.cfg.API.GetRepo(ctx, p, owner, repo) | |
| @@ -172,6 +185,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 172 | 185 | ||
| 173 | 186 | // route recognises the endpoints. ref is "" for /tarball. | |
| 174 | 187 | func route(rest string) (kind, owner, repo, ref string, ok bool) { | |
| 188 | + | if kind, owner, repo, ref, ok = releaseRoute(rest); ok { | |
| 189 | + | return | |
| 190 | + | } | |
| 175 | 191 | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | |
| 176 | 192 | if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" { | |
| 177 | 193 | return "", "", "", "", false | |
| @@ -68,6 +68,10 @@ const ( | |||||
| 68 | type Config struct { | 68 | type Config struct { | ||
| 69 | Auth ports.AuthService | 69 | Auth ports.AuthService | ||
| 70 | API ports.GitHubAPIService | 70 | API ports.GitHubAPIService | ||
| 71 | + | // Releases and ReleaseDownload enable the release REST routes. Nil services | |||
| 72 | + | // leave their routes unavailable with GitHub's 404 JSON. | |||
| 73 | + | Releases ports.GitHubReleaseAPI | |||
| 74 | + | ReleaseDownload ports.ReleaseService | |||
| 71 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | 75 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | ||
| 72 | TrustedProxies []netip.Prefix | 76 | TrustedProxies []netip.Prefix | ||
| 73 | // Limiter counts failed authentications; nil builds a default one. | 77 | // Limiter counts failed authentications; nil builds a default one. | ||
| @@ -124,8 +128,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 124 | notFound(w) | 128 | notFound(w) | ||
| 125 | return | 129 | return | ||
| 126 | } | 130 | } | ||
| 127 | - | if r.Method != http.MethodGet && r.Method != http.MethodHead { | 131 | + | if strings.HasPrefix(kind, "release") && h.cfg.Releases == nil { |
| 128 | - | w.Header().Set("Allow", "GET, HEAD") | 132 | + | notFound(w) |
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | allowed := releaseMethods(kind) | |||
| 136 | + | if !methodAllowed(r.Method, allowed) { | |||
| 137 | + | w.Header().Set("Allow", allowed) | |||
| 129 | jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed") | 138 | jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed") | ||
| 130 | return | 139 | return | ||
| 131 | } | 140 | } | ||
| @@ -148,6 +157,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 148 | return | 157 | return | ||
| 149 | } | 158 | } | ||
| 150 | 159 | ||||
| 160 | + | if strings.HasPrefix(kind, "release") { | |||
| 161 | + | h.releaseRequest(w, r, p, kind, owner, repo, ref, clientlimit.Key(addr)) | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 151 | switch kind { | 164 | switch kind { | ||
| 152 | case "repo": | 165 | case "repo": | ||
| 153 | body, err := h.cfg.API.GetRepo(ctx, p, owner, repo) | 166 | body, err := h.cfg.API.GetRepo(ctx, p, owner, repo) | ||
| @@ -172,6 +185,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 172 | 185 | ||||
| 173 | // route recognises the endpoints. ref is "" for /tarball. | 186 | // route recognises the endpoints. ref is "" for /tarball. | ||
| 174 | func route(rest string) (kind, owner, repo, ref string, ok bool) { | 187 | func route(rest string) (kind, owner, repo, ref string, ok bool) { | ||
| 188 | + | if kind, owner, repo, ref, ok = releaseRoute(rest); ok { | |||
| 189 | + | return | |||
| 190 | + | } | |||
| 175 | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | 191 | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | ||
| 176 | if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" { | 192 | if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" { | ||
| 177 | return "", "", "", "", false | 193 | return "", "", "", "", false | ||
A internal/adapters/githubapi/releases.go +444 -0
| @@ -0,0 +1,444 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package githubapi | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "io" | |
| 10 | + | "mime" | |
| 11 | + | "net/http" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "unicode" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "github.com/bnema/zerowrap" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) { | |
| 22 | + | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | |
| 23 | + | upload := len(parts) > 0 && parts[0] == "uploads" | |
| 24 | + | if upload { | |
| 25 | + | parts = parts[1:] | |
| 26 | + | } | |
| 27 | + | if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | owner, repo = parts[1], parts[2] | |
| 31 | + | if upload { | |
| 32 | + | if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" { | |
| 33 | + | return "release-upload", owner, repo, parts[4], true | |
| 34 | + | } | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | switch { | |
| 38 | + | case len(parts) == 4: | |
| 39 | + | return "release-list", owner, repo, "", true | |
| 40 | + | case len(parts) == 5 && parts[4] == "latest": | |
| 41 | + | return "release-latest", owner, repo, "", true | |
| 42 | + | case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "": | |
| 43 | + | return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true | |
| 44 | + | case len(parts) == 5 && isNumber(parts[4]): | |
| 45 | + | return "release-id", owner, repo, parts[4], true | |
| 46 | + | case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets": | |
| 47 | + | return "release-assets", owner, repo, parts[4], true | |
| 48 | + | case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]): | |
| 49 | + | return "release-asset", owner, repo, parts[5], true | |
| 50 | + | } | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | func releaseMethods(kind string) string { | |
| 54 | + | switch kind { | |
| 55 | + | case "release-list": | |
| 56 | + | return "GET, HEAD, POST" | |
| 57 | + | case "release-id", "release-asset": | |
| 58 | + | return "GET, HEAD, PATCH, DELETE" | |
| 59 | + | case "release-upload": | |
| 60 | + | return "POST" | |
| 61 | + | default: | |
| 62 | + | return "GET, HEAD" | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | func methodAllowed(method, allow string) bool { | |
| 66 | + | for _, m := range strings.Split(allow, ", ") { | |
| 67 | + | if method == m { | |
| 68 | + | return true | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | return false | |
| 72 | + | } | |
| 73 | + | func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) { | |
| 74 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 75 | + | w.Header().Set("Content-Length", strconv.Itoa(len(body))) | |
| 76 | + | w.WriteHeader(status) | |
| 77 | + | if r.Method != http.MethodHead { | |
| 78 | + | _, _ = w.Write(body) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | func releaseMessage(w http.ResponseWriter, status int, message string) { | |
| 82 | + | b, _ := json.Marshal(struct { | |
| 83 | + | Message string `json:"message"` | |
| 84 | + | }{message}) | |
| 85 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 86 | + | w.WriteHeader(status) | |
| 87 | + | _, _ = w.Write(b) | |
| 88 | + | } | |
| 89 | + | func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { | |
| 90 | + | b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) | |
| 91 | + | if err != nil { | |
| 92 | + | var tooLarge *http.MaxBytesError | |
| 93 | + | if errors.As(err, &tooLarge) { | |
| 94 | + | releaseMessage(w, 422, "Body too large") | |
| 95 | + | } else { | |
| 96 | + | releaseMessage(w, 422, "Validation Failed") | |
| 97 | + | } | |
| 98 | + | return nil, false | |
| 99 | + | } | |
| 100 | + | return b, true | |
| 101 | + | } | |
| 102 | + | func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) { | |
| 103 | + | ctx := r.Context() | |
| 104 | + | api := h.cfg.Releases | |
| 105 | + | id, _ := strconv.ParseInt(ref, 10, 64) | |
| 106 | + | q := r.URL.Query() | |
| 107 | + | page, _ := strconv.Atoi(q.Get("page")) | |
| 108 | + | perPage, _ := strconv.Atoi(q.Get("per_page")) | |
| 109 | + | var body []byte | |
| 110 | + | var err error | |
| 111 | + | var link string | |
| 112 | + | status := http.StatusOK | |
| 113 | + | if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") { | |
| 114 | + | if h.cfg.ReleaseDownload == nil { | |
| 115 | + | notFound(w) | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | h.downloadRelease(w, r, p, owner, repo, id, client) | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | switch kind { | |
| 122 | + | case "release-list": | |
| 123 | + | if r.Method == http.MethodPost { | |
| 124 | + | b, ok := releaseBody(w, r) | |
| 125 | + | if !ok { | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | body, err = api.CreateRelease(ctx, p, owner, repo, b) | |
| 129 | + | status = 201 | |
| 130 | + | } else { | |
| 131 | + | body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage) | |
| 132 | + | } | |
| 133 | + | case "release-latest": | |
| 134 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true}) | |
| 135 | + | case "release-tag": | |
| 136 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref}) | |
| 137 | + | case "release-id": | |
| 138 | + | switch r.Method { | |
| 139 | + | case http.MethodPatch: | |
| 140 | + | b, ok := releaseBody(w, r) | |
| 141 | + | if !ok { | |
| 142 | + | return | |
| 143 | + | } | |
| 144 | + | body, err = api.UpdateRelease(ctx, p, owner, repo, id, b) | |
| 145 | + | case http.MethodDelete: | |
| 146 | + | err = api.DeleteRelease(ctx, p, owner, repo, id) | |
| 147 | + | status = 204 | |
| 148 | + | default: | |
| 149 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id}) | |
| 150 | + | } | |
| 151 | + | case "release-assets": | |
| 152 | + | body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage) | |
| 153 | + | case "release-upload": | |
| 154 | + | if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") { | |
| 155 | + | releaseMessage(w, 411, "Length Required") | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | if q.Get("name") == "" { | |
| 159 | + | h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name") | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | ct := r.Header.Get("Content-Type") | |
| 163 | + | if ct == "" { | |
| 164 | + | ct = "application/octet-stream" | |
| 165 | + | } | |
| 166 | + | body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength)) | |
| 167 | + | status = 201 | |
| 168 | + | case "release-asset": | |
| 169 | + | switch r.Method { | |
| 170 | + | case http.MethodPatch: | |
| 171 | + | b, ok := releaseBody(w, r) | |
| 172 | + | if !ok { | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | body, err = api.UpdateAsset(ctx, p, owner, repo, id, b) | |
| 176 | + | case http.MethodDelete: | |
| 177 | + | err = api.DeleteAsset(ctx, p, owner, repo, id) | |
| 178 | + | status = 204 | |
| 179 | + | default: | |
| 180 | + | body, err = api.GetAsset(ctx, p, owner, repo, id) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | if err != nil { | |
| 184 | + | h.failRelease(ctx, w, p, err, kind, "") | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | if link != "" { | |
| 188 | + | w.Header().Set("Link", link) | |
| 189 | + | } | |
| 190 | + | if status == 204 { | |
| 191 | + | w.WriteHeader(status) | |
| 192 | + | return | |
| 193 | + | } | |
| 194 | + | releaseJSON(w, r, status, body) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // failRelease translates use-case failures to GitHub's status and error JSON; | |
| 198 | + | // authorization and limits have already been decided by ReleaseService. | |
| 199 | + | // | |
| 200 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here | |
| 201 | + | func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) { | |
| 202 | + | switch { | |
| 203 | + | case errors.Is(err, domain.ErrNotFound): | |
| 204 | + | notFound(w) | |
| 205 | + | case errors.Is(err, domain.ErrUnauthenticated): | |
| 206 | + | releaseMessage(w, 401, "Bad credentials") | |
| 207 | + | case errors.Is(err, domain.ErrForbidden): | |
| 208 | + | msg := "Forbidden" | |
| 209 | + | if p.ViaJobToken() { | |
| 210 | + | msg = "Resource not accessible by integration" | |
| 211 | + | } | |
| 212 | + | releaseMessage(w, 403, msg) | |
| 213 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 214 | + | if strings.Contains(err.Error(), "too many uploads in progress") { | |
| 215 | + | w.Header().Set("Retry-After", retryAfter) | |
| 216 | + | releaseMessage(w, 429, err.Error()) | |
| 217 | + | } else { | |
| 218 | + | releaseMessage(w, 422, err.Error()) | |
| 219 | + | } | |
| 220 | + | case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed): | |
| 221 | + | writeReleaseValidation(w, err, kind, field) | |
| 222 | + | case errors.Is(err, context.Canceled): | |
| 223 | + | default: | |
| 224 | + | log := zerowrap.FromCtx(ctx) | |
| 225 | + | log.Error().Err(err).Msg("github release request failed") | |
| 226 | + | releaseMessage(w, 500, "Server Error") | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | type releaseValidationError struct { | |
| 231 | + | Resource string `json:"resource"` | |
| 232 | + | Code string `json:"code"` | |
| 233 | + | Field string `json:"field,omitempty"` | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) { | |
| 237 | + | resource, code, doc := "Release", "invalid", "" | |
| 238 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |
| 239 | + | code = "already_exists" | |
| 240 | + | field = "tag_name" | |
| 241 | + | if kind == "release-upload" || kind == "release-asset" { | |
| 242 | + | resource = "ReleaseAsset" | |
| 243 | + | field = "name" | |
| 244 | + | } | |
| 245 | + | if kind == "release-upload" { | |
| 246 | + | doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset" | |
| 247 | + | } | |
| 248 | + | } else if errors.Is(err, domain.ErrPreconditionFailed) { | |
| 249 | + | field = "target_commitish" | |
| 250 | + | } else if field == "" { | |
| 251 | + | field = releaseErrorField(err.Error()) | |
| 252 | + | } | |
| 253 | + | b, _ := json.Marshal(struct { | |
| 254 | + | Message string `json:"message"` | |
| 255 | + | Errors []releaseValidationError `json:"errors"` | |
| 256 | + | DocumentationURL string `json:"documentation_url,omitempty"` | |
| 257 | + | }{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc}) | |
| 258 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 259 | + | w.WriteHeader(422) | |
| 260 | + | _, _ = w.Write(b) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // releaseErrorField recognises the wire fields and domain validation vocabulary. | |
| 264 | + | // Errors without a named field omit it rather than guessing. | |
| 265 | + | func releaseErrorField(text string) string { | |
| 266 | + | for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} { | |
| 267 | + | if strings.Contains(text, f) { | |
| 268 | + | return f | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | if strings.Contains(text, "tag name") { | |
| 272 | + | return "tag_name" | |
| 273 | + | } | |
| 274 | + | if strings.Contains(text, "media type") { | |
| 275 | + | return "content_type" | |
| 276 | + | } | |
| 277 | + | if strings.Contains(text, "name") { | |
| 278 | + | return "name" | |
| 279 | + | } | |
| 280 | + | return "" | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // assetRange returns a single byte range. Multiple ranges and unknown units are | |
| 284 | + | // ignored. Unsatisfiable or malformed byte ranges return false. | |
| 285 | + | func assetRange(header string, size int64) (offset, length int64, partial, ok bool) { | |
| 286 | + | if header == "" || !strings.HasPrefix(header, "bytes=") || strings.Contains(header, ",") { | |
| 287 | + | return 0, size, false, true | |
| 288 | + | } | |
| 289 | + | spec := strings.TrimSpace(strings.TrimPrefix(header, "bytes=")) | |
| 290 | + | a, b, found := strings.Cut(spec, "-") | |
| 291 | + | if !found || size <= 0 { | |
| 292 | + | return 0, 0, false, false | |
| 293 | + | } | |
| 294 | + | if a == "" { | |
| 295 | + | n, err := strconv.ParseInt(b, 10, 64) | |
| 296 | + | if err != nil || n <= 0 { | |
| 297 | + | return 0, 0, false, false | |
| 298 | + | } | |
| 299 | + | n = min(n, size) | |
| 300 | + | return size - n, n, true, true | |
| 301 | + | } | |
| 302 | + | start, err := strconv.ParseInt(a, 10, 64) | |
| 303 | + | if err != nil || start < 0 || start >= size { | |
| 304 | + | return 0, 0, false, false | |
| 305 | + | } | |
| 306 | + | end := size - 1 | |
| 307 | + | if b != "" { | |
| 308 | + | end, err = strconv.ParseInt(b, 10, 64) | |
| 309 | + | if err != nil || end < start { | |
| 310 | + | return 0, 0, false, false | |
| 311 | + | } | |
| 312 | + | end = min(end, size-1) | |
| 313 | + | } | |
| 314 | + | return start, end - start + 1, true, true | |
| 315 | + | } | |
| 316 | + | func releaseContentDisposition(name string) string { | |
| 317 | + | name = strings.Map(func(r rune) rune { | |
| 318 | + | if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar { | |
| 319 | + | return -1 | |
| 320 | + | } | |
| 321 | + | return r | |
| 322 | + | }, name) | |
| 323 | + | if name == "" || name == "." || name == ".." { | |
| 324 | + | name = "asset" | |
| 325 | + | } | |
| 326 | + | ascii := true | |
| 327 | + | fallback := strings.Map(func(r rune) rune { | |
| 328 | + | if r > unicode.MaxASCII { | |
| 329 | + | ascii = false | |
| 330 | + | return '_' | |
| 331 | + | } | |
| 332 | + | return r | |
| 333 | + | }, name) | |
| 334 | + | // Quote even token-safe names, keeping a consistent RFC 6266 representation. | |
| 335 | + | cd := "attachment; filename=" + strconv.Quote(fallback) | |
| 336 | + | if !ascii { | |
| 337 | + | const digits = "0123456789ABCDEF" | |
| 338 | + | var b strings.Builder | |
| 339 | + | for i := 0; i < len(name); i++ { | |
| 340 | + | c := name[i] | |
| 341 | + | if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 { | |
| 342 | + | b.WriteByte(c) | |
| 343 | + | } else { | |
| 344 | + | b.WriteByte('%') | |
| 345 | + | b.WriteByte(digits[c>>4]) | |
| 346 | + | b.WriteByte(digits[c&15]) | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | cd += "; filename*=UTF-8''" + b.String() | |
| 350 | + | } | |
| 351 | + | return cd | |
| 352 | + | } | |
| 353 | + | func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string { | |
| 354 | + | ct := a.ContentType | |
| 355 | + | media, _, err := mime.ParseMediaType(ct) | |
| 356 | + | media = strings.ToLower(media) | |
| 357 | + | if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" { | |
| 358 | + | ct = "application/octet-stream" | |
| 359 | + | } | |
| 360 | + | w.Header().Set("Content-Type", ct) | |
| 361 | + | w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name)) | |
| 362 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 363 | + | w.Header().Set("Content-Security-Policy", "sandbox") | |
| 364 | + | w.Header().Set("Accept-Ranges", "bytes") | |
| 365 | + | etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"` | |
| 366 | + | w.Header().Set("ETag", etag) | |
| 367 | + | w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat)) | |
| 368 | + | return etag | |
| 369 | + | } | |
| 370 | + | func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) { | |
| 371 | + | ctx := r.Context() | |
| 372 | + | a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id) | |
| 373 | + | if err != nil { | |
| 374 | + | h.failRelease(ctx, w, p, err, "release-asset", "") | |
| 375 | + | return | |
| 376 | + | } | |
| 377 | + | if r.Method != http.MethodHead { | |
| 378 | + | release, ok := h.acquire(w, client) | |
| 379 | + | if !ok { | |
| 380 | + | return | |
| 381 | + | } | |
| 382 | + | defer release() | |
| 383 | + | } | |
| 384 | + | etag := setReleaseDownloadHeaders(w, a) | |
| 385 | + | if r.Header.Get("If-None-Match") == etag { | |
| 386 | + | w.WriteHeader(304) | |
| 387 | + | return | |
| 388 | + | } | |
| 389 | + | offset, length, partial, ok := assetRange(r.Header.Get("Range"), a.Size) | |
| 390 | + | if !ok { | |
| 391 | + | w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10)) | |
| 392 | + | w.WriteHeader(416) | |
| 393 | + | return | |
| 394 | + | } | |
| 395 | + | status := 200 | |
| 396 | + | if partial { | |
| 397 | + | status = 206 | |
| 398 | + | w.Header().Set("Content-Range", "bytes "+strconv.FormatInt(offset, 10)+"-"+strconv.FormatInt(offset+length-1, 10)+"/"+strconv.FormatInt(a.Size, 10)) | |
| 399 | + | } | |
| 400 | + | w.Header().Set("Content-Length", strconv.FormatInt(length, 10)) | |
| 401 | + | if r.Method == http.MethodHead { | |
| 402 | + | w.WriteHeader(status) | |
| 403 | + | return | |
| 404 | + | } | |
| 405 | + | // Slot failures and pre-stream failures are JSON, not a binary response. | |
| 406 | + | clearHeaders := func() { | |
| 407 | + | for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} { | |
| 408 | + | w.Header().Del(k) | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle} | |
| 412 | + | started := false | |
| 413 | + | err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error { | |
| 414 | + | if len(chunk.Data) == 0 { | |
| 415 | + | return nil | |
| 416 | + | } | |
| 417 | + | if !started { | |
| 418 | + | w.WriteHeader(status) | |
| 419 | + | started = true | |
| 420 | + | } | |
| 421 | + | _, e := writer.Write(chunk.Data) | |
| 422 | + | return e | |
| 423 | + | }) | |
| 424 | + | // BlobStore.Open reports offset >= size as out of range, including the | |
| 425 | + | // valid empty object at offset zero. It has still checked existence. | |
| 426 | + | if err == nil || (a.Size == 0 && errors.Is(err, domain.ErrOutOfRange)) { | |
| 427 | + | if !started { | |
| 428 | + | w.WriteHeader(status) | |
| 429 | + | } | |
| 430 | + | return | |
| 431 | + | } | |
| 432 | + | if started { | |
| 433 | + | log := zerowrap.FromCtx(ctx) | |
| 434 | + | log.Warn().Err(err).Msg("github release download cut short") | |
| 435 | + | panic(http.ErrAbortHandler) | |
| 436 | + | } | |
| 437 | + | clearHeaders() | |
| 438 | + | if errors.Is(err, domain.ErrOutOfRange) { | |
| 439 | + | w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10)) | |
| 440 | + | w.WriteHeader(416) | |
| 441 | + | return | |
| 442 | + | } | |
| 443 | + | h.failRelease(ctx, w, p, err, "release-asset", "") | |
| 444 | + | } | |
| @@ -0,0 +1,444 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package githubapi | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "io" | |||
| 10 | + | "mime" | |||
| 11 | + | "net/http" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | "unicode" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | "github.com/bnema/zerowrap" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) { | |||
| 22 | + | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | |||
| 23 | + | upload := len(parts) > 0 && parts[0] == "uploads" | |||
| 24 | + | if upload { | |||
| 25 | + | parts = parts[1:] | |||
| 26 | + | } | |||
| 27 | + | if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | owner, repo = parts[1], parts[2] | |||
| 31 | + | if upload { | |||
| 32 | + | if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" { | |||
| 33 | + | return "release-upload", owner, repo, parts[4], true | |||
| 34 | + | } | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | switch { | |||
| 38 | + | case len(parts) == 4: | |||
| 39 | + | return "release-list", owner, repo, "", true | |||
| 40 | + | case len(parts) == 5 && parts[4] == "latest": | |||
| 41 | + | return "release-latest", owner, repo, "", true | |||
| 42 | + | case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "": | |||
| 43 | + | return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true | |||
| 44 | + | case len(parts) == 5 && isNumber(parts[4]): | |||
| 45 | + | return "release-id", owner, repo, parts[4], true | |||
| 46 | + | case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets": | |||
| 47 | + | return "release-assets", owner, repo, parts[4], true | |||
| 48 | + | case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]): | |||
| 49 | + | return "release-asset", owner, repo, parts[5], true | |||
| 50 | + | } | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | func releaseMethods(kind string) string { | |||
| 54 | + | switch kind { | |||
| 55 | + | case "release-list": | |||
| 56 | + | return "GET, HEAD, POST" | |||
| 57 | + | case "release-id", "release-asset": | |||
| 58 | + | return "GET, HEAD, PATCH, DELETE" | |||
| 59 | + | case "release-upload": | |||
| 60 | + | return "POST" | |||
| 61 | + | default: | |||
| 62 | + | return "GET, HEAD" | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | func methodAllowed(method, allow string) bool { | |||
| 66 | + | for _, m := range strings.Split(allow, ", ") { | |||
| 67 | + | if method == m { | |||
| 68 | + | return true | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | return false | |||
| 72 | + | } | |||
| 73 | + | func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) { | |||
| 74 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |||
| 75 | + | w.Header().Set("Content-Length", strconv.Itoa(len(body))) | |||
| 76 | + | w.WriteHeader(status) | |||
| 77 | + | if r.Method != http.MethodHead { | |||
| 78 | + | _, _ = w.Write(body) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | func releaseMessage(w http.ResponseWriter, status int, message string) { | |||
| 82 | + | b, _ := json.Marshal(struct { | |||
| 83 | + | Message string `json:"message"` | |||
| 84 | + | }{message}) | |||
| 85 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |||
| 86 | + | w.WriteHeader(status) | |||
| 87 | + | _, _ = w.Write(b) | |||
| 88 | + | } | |||
| 89 | + | func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { | |||
| 90 | + | b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) | |||
| 91 | + | if err != nil { | |||
| 92 | + | var tooLarge *http.MaxBytesError | |||
| 93 | + | if errors.As(err, &tooLarge) { | |||
| 94 | + | releaseMessage(w, 422, "Body too large") | |||
| 95 | + | } else { | |||
| 96 | + | releaseMessage(w, 422, "Validation Failed") | |||
| 97 | + | } | |||
| 98 | + | return nil, false | |||
| 99 | + | } | |||
| 100 | + | return b, true | |||
| 101 | + | } | |||
| 102 | + | func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) { | |||
| 103 | + | ctx := r.Context() | |||
| 104 | + | api := h.cfg.Releases | |||
| 105 | + | id, _ := strconv.ParseInt(ref, 10, 64) | |||
| 106 | + | q := r.URL.Query() | |||
| 107 | + | page, _ := strconv.Atoi(q.Get("page")) | |||
| 108 | + | perPage, _ := strconv.Atoi(q.Get("per_page")) | |||
| 109 | + | var body []byte | |||
| 110 | + | var err error | |||
| 111 | + | var link string | |||
| 112 | + | status := http.StatusOK | |||
| 113 | + | if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") { | |||
| 114 | + | if h.cfg.ReleaseDownload == nil { | |||
| 115 | + | notFound(w) | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | h.downloadRelease(w, r, p, owner, repo, id, client) | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | switch kind { | |||
| 122 | + | case "release-list": | |||
| 123 | + | if r.Method == http.MethodPost { | |||
| 124 | + | b, ok := releaseBody(w, r) | |||
| 125 | + | if !ok { | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | body, err = api.CreateRelease(ctx, p, owner, repo, b) | |||
| 129 | + | status = 201 | |||
| 130 | + | } else { | |||
| 131 | + | body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage) | |||
| 132 | + | } | |||
| 133 | + | case "release-latest": | |||
| 134 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true}) | |||
| 135 | + | case "release-tag": | |||
| 136 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref}) | |||
| 137 | + | case "release-id": | |||
| 138 | + | switch r.Method { | |||
| 139 | + | case http.MethodPatch: | |||
| 140 | + | b, ok := releaseBody(w, r) | |||
| 141 | + | if !ok { | |||
| 142 | + | return | |||
| 143 | + | } | |||
| 144 | + | body, err = api.UpdateRelease(ctx, p, owner, repo, id, b) | |||
| 145 | + | case http.MethodDelete: | |||
| 146 | + | err = api.DeleteRelease(ctx, p, owner, repo, id) | |||
| 147 | + | status = 204 | |||
| 148 | + | default: | |||
| 149 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id}) | |||
| 150 | + | } | |||
| 151 | + | case "release-assets": | |||
| 152 | + | body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage) | |||
| 153 | + | case "release-upload": | |||
| 154 | + | if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") { | |||
| 155 | + | releaseMessage(w, 411, "Length Required") | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | if q.Get("name") == "" { | |||
| 159 | + | h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name") | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | ct := r.Header.Get("Content-Type") | |||
| 163 | + | if ct == "" { | |||
| 164 | + | ct = "application/octet-stream" | |||
| 165 | + | } | |||
| 166 | + | body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength)) | |||
| 167 | + | status = 201 | |||
| 168 | + | case "release-asset": | |||
| 169 | + | switch r.Method { | |||
| 170 | + | case http.MethodPatch: | |||
| 171 | + | b, ok := releaseBody(w, r) | |||
| 172 | + | if !ok { | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | body, err = api.UpdateAsset(ctx, p, owner, repo, id, b) | |||
| 176 | + | case http.MethodDelete: | |||
| 177 | + | err = api.DeleteAsset(ctx, p, owner, repo, id) | |||
| 178 | + | status = 204 | |||
| 179 | + | default: | |||
| 180 | + | body, err = api.GetAsset(ctx, p, owner, repo, id) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | if err != nil { | |||
| 184 | + | h.failRelease(ctx, w, p, err, kind, "") | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | if link != "" { | |||
| 188 | + | w.Header().Set("Link", link) | |||
| 189 | + | } | |||
| 190 | + | if status == 204 { | |||
| 191 | + | w.WriteHeader(status) | |||
| 192 | + | return | |||
| 193 | + | } | |||
| 194 | + | releaseJSON(w, r, status, body) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // failRelease translates use-case failures to GitHub's status and error JSON; | |||
| 198 | + | // authorization and limits have already been decided by ReleaseService. | |||
| 199 | + | // | |||
| 200 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here | |||
| 201 | + | func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) { | |||
| 202 | + | switch { | |||
| 203 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 204 | + | notFound(w) | |||
| 205 | + | case errors.Is(err, domain.ErrUnauthenticated): | |||
| 206 | + | releaseMessage(w, 401, "Bad credentials") | |||
| 207 | + | case errors.Is(err, domain.ErrForbidden): | |||
| 208 | + | msg := "Forbidden" | |||
| 209 | + | if p.ViaJobToken() { | |||
| 210 | + | msg = "Resource not accessible by integration" | |||
| 211 | + | } | |||
| 212 | + | releaseMessage(w, 403, msg) | |||
| 213 | + | case errors.Is(err, domain.ErrResourceExhausted): | |||
| 214 | + | if strings.Contains(err.Error(), "too many uploads in progress") { | |||
| 215 | + | w.Header().Set("Retry-After", retryAfter) | |||
| 216 | + | releaseMessage(w, 429, err.Error()) | |||
| 217 | + | } else { | |||
| 218 | + | releaseMessage(w, 422, err.Error()) | |||
| 219 | + | } | |||
| 220 | + | case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed): | |||
| 221 | + | writeReleaseValidation(w, err, kind, field) | |||
| 222 | + | case errors.Is(err, context.Canceled): | |||
| 223 | + | default: | |||
| 224 | + | log := zerowrap.FromCtx(ctx) | |||
| 225 | + | log.Error().Err(err).Msg("github release request failed") | |||
| 226 | + | releaseMessage(w, 500, "Server Error") | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | type releaseValidationError struct { | |||
| 231 | + | Resource string `json:"resource"` | |||
| 232 | + | Code string `json:"code"` | |||
| 233 | + | Field string `json:"field,omitempty"` | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) { | |||
| 237 | + | resource, code, doc := "Release", "invalid", "" | |||
| 238 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |||
| 239 | + | code = "already_exists" | |||
| 240 | + | field = "tag_name" | |||
| 241 | + | if kind == "release-upload" || kind == "release-asset" { | |||
| 242 | + | resource = "ReleaseAsset" | |||
| 243 | + | field = "name" | |||
| 244 | + | } | |||
| 245 | + | if kind == "release-upload" { | |||
| 246 | + | doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset" | |||
| 247 | + | } | |||
| 248 | + | } else if errors.Is(err, domain.ErrPreconditionFailed) { | |||
| 249 | + | field = "target_commitish" | |||
| 250 | + | } else if field == "" { | |||
| 251 | + | field = releaseErrorField(err.Error()) | |||
| 252 | + | } | |||
| 253 | + | b, _ := json.Marshal(struct { | |||
| 254 | + | Message string `json:"message"` | |||
| 255 | + | Errors []releaseValidationError `json:"errors"` | |||
| 256 | + | DocumentationURL string `json:"documentation_url,omitempty"` | |||
| 257 | + | }{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc}) | |||
| 258 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |||
| 259 | + | w.WriteHeader(422) | |||
| 260 | + | _, _ = w.Write(b) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | // releaseErrorField recognises the wire fields and domain validation vocabulary. | |||
| 264 | + | // Errors without a named field omit it rather than guessing. | |||
| 265 | + | func releaseErrorField(text string) string { | |||
| 266 | + | for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} { | |||
| 267 | + | if strings.Contains(text, f) { | |||
| 268 | + | return f | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | if strings.Contains(text, "tag name") { | |||
| 272 | + | return "tag_name" | |||
| 273 | + | } | |||
| 274 | + | if strings.Contains(text, "media type") { | |||
| 275 | + | return "content_type" | |||
| 276 | + | } | |||
| 277 | + | if strings.Contains(text, "name") { | |||
| 278 | + | return "name" | |||
| 279 | + | } | |||
| 280 | + | return "" | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // assetRange returns a single byte range. Multiple ranges and unknown units are | |||
| 284 | + | // ignored. Unsatisfiable or malformed byte ranges return false. | |||
| 285 | + | func assetRange(header string, size int64) (offset, length int64, partial, ok bool) { | |||
| 286 | + | if header == "" || !strings.HasPrefix(header, "bytes=") || strings.Contains(header, ",") { | |||
| 287 | + | return 0, size, false, true | |||
| 288 | + | } | |||
| 289 | + | spec := strings.TrimSpace(strings.TrimPrefix(header, "bytes=")) | |||
| 290 | + | a, b, found := strings.Cut(spec, "-") | |||
| 291 | + | if !found || size <= 0 { | |||
| 292 | + | return 0, 0, false, false | |||
| 293 | + | } | |||
| 294 | + | if a == "" { | |||
| 295 | + | n, err := strconv.ParseInt(b, 10, 64) | |||
| 296 | + | if err != nil || n <= 0 { | |||
| 297 | + | return 0, 0, false, false | |||
| 298 | + | } | |||
| 299 | + | n = min(n, size) | |||
| 300 | + | return size - n, n, true, true | |||
| 301 | + | } | |||
| 302 | + | start, err := strconv.ParseInt(a, 10, 64) | |||
| 303 | + | if err != nil || start < 0 || start >= size { | |||
| 304 | + | return 0, 0, false, false | |||
| 305 | + | } | |||
| 306 | + | end := size - 1 | |||
| 307 | + | if b != "" { | |||
| 308 | + | end, err = strconv.ParseInt(b, 10, 64) | |||
| 309 | + | if err != nil || end < start { | |||
| 310 | + | return 0, 0, false, false | |||
| 311 | + | } | |||
| 312 | + | end = min(end, size-1) | |||
| 313 | + | } | |||
| 314 | + | return start, end - start + 1, true, true | |||
| 315 | + | } | |||
| 316 | + | func releaseContentDisposition(name string) string { | |||
| 317 | + | name = strings.Map(func(r rune) rune { | |||
| 318 | + | if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar { | |||
| 319 | + | return -1 | |||
| 320 | + | } | |||
| 321 | + | return r | |||
| 322 | + | }, name) | |||
| 323 | + | if name == "" || name == "." || name == ".." { | |||
| 324 | + | name = "asset" | |||
| 325 | + | } | |||
| 326 | + | ascii := true | |||
| 327 | + | fallback := strings.Map(func(r rune) rune { | |||
| 328 | + | if r > unicode.MaxASCII { | |||
| 329 | + | ascii = false | |||
| 330 | + | return '_' | |||
| 331 | + | } | |||
| 332 | + | return r | |||
| 333 | + | }, name) | |||
| 334 | + | // Quote even token-safe names, keeping a consistent RFC 6266 representation. | |||
| 335 | + | cd := "attachment; filename=" + strconv.Quote(fallback) | |||
| 336 | + | if !ascii { | |||
| 337 | + | const digits = "0123456789ABCDEF" | |||
| 338 | + | var b strings.Builder | |||
| 339 | + | for i := 0; i < len(name); i++ { | |||
| 340 | + | c := name[i] | |||
| 341 | + | if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 { | |||
| 342 | + | b.WriteByte(c) | |||
| 343 | + | } else { | |||
| 344 | + | b.WriteByte('%') | |||
| 345 | + | b.WriteByte(digits[c>>4]) | |||
| 346 | + | b.WriteByte(digits[c&15]) | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | cd += "; filename*=UTF-8''" + b.String() | |||
| 350 | + | } | |||
| 351 | + | return cd | |||
| 352 | + | } | |||
| 353 | + | func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string { | |||
| 354 | + | ct := a.ContentType | |||
| 355 | + | media, _, err := mime.ParseMediaType(ct) | |||
| 356 | + | media = strings.ToLower(media) | |||
| 357 | + | if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" { | |||
| 358 | + | ct = "application/octet-stream" | |||
| 359 | + | } | |||
| 360 | + | w.Header().Set("Content-Type", ct) | |||
| 361 | + | w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name)) | |||
| 362 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |||
| 363 | + | w.Header().Set("Content-Security-Policy", "sandbox") | |||
| 364 | + | w.Header().Set("Accept-Ranges", "bytes") | |||
| 365 | + | etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"` | |||
| 366 | + | w.Header().Set("ETag", etag) | |||
| 367 | + | w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat)) | |||
| 368 | + | return etag | |||
| 369 | + | } | |||
| 370 | + | func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) { | |||
| 371 | + | ctx := r.Context() | |||
| 372 | + | a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id) | |||
| 373 | + | if err != nil { | |||
| 374 | + | h.failRelease(ctx, w, p, err, "release-asset", "") | |||
| 375 | + | return | |||
| 376 | + | } | |||
| 377 | + | if r.Method != http.MethodHead { | |||
| 378 | + | release, ok := h.acquire(w, client) | |||
| 379 | + | if !ok { | |||
| 380 | + | return | |||
| 381 | + | } | |||
| 382 | + | defer release() | |||
| 383 | + | } | |||
| 384 | + | etag := setReleaseDownloadHeaders(w, a) | |||
| 385 | + | if r.Header.Get("If-None-Match") == etag { | |||
| 386 | + | w.WriteHeader(304) | |||
| 387 | + | return | |||
| 388 | + | } | |||
| 389 | + | offset, length, partial, ok := assetRange(r.Header.Get("Range"), a.Size) | |||
| 390 | + | if !ok { | |||
| 391 | + | w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10)) | |||
| 392 | + | w.WriteHeader(416) | |||
| 393 | + | return | |||
| 394 | + | } | |||
| 395 | + | status := 200 | |||
| 396 | + | if partial { | |||
| 397 | + | status = 206 | |||
| 398 | + | w.Header().Set("Content-Range", "bytes "+strconv.FormatInt(offset, 10)+"-"+strconv.FormatInt(offset+length-1, 10)+"/"+strconv.FormatInt(a.Size, 10)) | |||
| 399 | + | } | |||
| 400 | + | w.Header().Set("Content-Length", strconv.FormatInt(length, 10)) | |||
| 401 | + | if r.Method == http.MethodHead { | |||
| 402 | + | w.WriteHeader(status) | |||
| 403 | + | return | |||
| 404 | + | } | |||
| 405 | + | // Slot failures and pre-stream failures are JSON, not a binary response. | |||
| 406 | + | clearHeaders := func() { | |||
| 407 | + | for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} { | |||
| 408 | + | w.Header().Del(k) | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle} | |||
| 412 | + | started := false | |||
| 413 | + | err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error { | |||
| 414 | + | if len(chunk.Data) == 0 { | |||
| 415 | + | return nil | |||
| 416 | + | } | |||
| 417 | + | if !started { | |||
| 418 | + | w.WriteHeader(status) | |||
| 419 | + | started = true | |||
| 420 | + | } | |||
| 421 | + | _, e := writer.Write(chunk.Data) | |||
| 422 | + | return e | |||
| 423 | + | }) | |||
| 424 | + | // BlobStore.Open reports offset >= size as out of range, including the | |||
| 425 | + | // valid empty object at offset zero. It has still checked existence. | |||
| 426 | + | if err == nil || (a.Size == 0 && errors.Is(err, domain.ErrOutOfRange)) { | |||
| 427 | + | if !started { | |||
| 428 | + | w.WriteHeader(status) | |||
| 429 | + | } | |||
| 430 | + | return | |||
| 431 | + | } | |||
| 432 | + | if started { | |||
| 433 | + | log := zerowrap.FromCtx(ctx) | |||
| 434 | + | log.Warn().Err(err).Msg("github release download cut short") | |||
| 435 | + | panic(http.ErrAbortHandler) | |||
| 436 | + | } | |||
| 437 | + | clearHeaders() | |||
| 438 | + | if errors.Is(err, domain.ErrOutOfRange) { | |||
| 439 | + | w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10)) | |||
| 440 | + | w.WriteHeader(416) | |||
| 441 | + | return | |||
| 442 | + | } | |||
| 443 | + | h.failRelease(ctx, w, p, err, "release-asset", "") | |||
| 444 | + | } | |||
A internal/adapters/githubapi/releases_test.go +408 -0
| @@ -0,0 +1,408 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package githubapi | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "net/http" | |
| 12 | + | "net/http/httptest" | |
| 13 | + | "net/url" | |
| 14 | + | "os" | |
| 15 | + | "strings" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | + | "github.com/stretchr/testify/mock" | |
| 23 | + | "github.com/stretchr/testify/require" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | const releasePath = "/repos/acme/proj/releases" | |
| 27 | + | const releaseAssetPath = releasePath + "/assets/7" | |
| 28 | + | const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets" | |
| 29 | + | ||
| 30 | + | func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { | |
| 31 | + | t.Helper() | |
| 32 | + | req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body)) | |
| 33 | + | for k, v := range headers { | |
| 34 | + | req.Header.Set(k, v) | |
| 35 | + | } | |
| 36 | + | w := httptest.NewRecorder() | |
| 37 | + | h.ServeHTTP(w, req) | |
| 38 | + | return w | |
| 39 | + | } | |
| 40 | + | func TestReleaseRoutes(t *testing.T) { | |
| 41 | + | cases := []struct{ path, kind, ref, allow string }{ | |
| 42 | + | {releasePath, "release-list", "", "GET, HEAD, POST"}, | |
| 43 | + | {releasePath + "/latest", "release-latest", "", "GET, HEAD"}, | |
| 44 | + | {releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"}, | |
| 45 | + | {releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"}, | |
| 46 | + | {releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"}, | |
| 47 | + | {releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"}, | |
| 48 | + | {releaseUploadPath, "release-upload", "4", "POST"}, | |
| 49 | + | } | |
| 50 | + | for _, tc := range cases { | |
| 51 | + | t.Run(tc.kind, func(t *testing.T) { | |
| 52 | + | kind, owner, repo, ref, ok := route(tc.path) | |
| 53 | + | require.True(t, ok) | |
| 54 | + | require.Equal(t, tc.kind, kind) | |
| 55 | + | require.Equal(t, "acme", owner) | |
| 56 | + | require.Equal(t, "proj", repo) | |
| 57 | + | require.Equal(t, tc.ref, ref) | |
| 58 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 59 | + | h := New(Config{Releases: api}) | |
| 60 | + | for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} { | |
| 61 | + | if methodAllowed(method, tc.allow) { | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | w := releaseRequest(t, h, method, tc.path, "", nil) | |
| 65 | + | require.Equal(t, 405, w.Code) | |
| 66 | + | require.Equal(t, tc.allow, w.Header().Get("Allow")) | |
| 67 | + | } | |
| 68 | + | w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil) | |
| 69 | + | require.Equal(t, 404, w.Code) | |
| 70 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} { | |
| 74 | + | _, _, _, _, ok := route(path) | |
| 75 | + | require.False(t, ok, path) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | func TestReleaseDispatch(t *testing.T) { | |
| 79 | + | for _, method := range []string{"GET", "HEAD"} { | |
| 80 | + | t.Run(method, func(t *testing.T) { | |
| 81 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 82 | + | h := New(Config{Releases: api}) | |
| 83 | + | api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once() | |
| 84 | + | w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil) | |
| 85 | + | require.Equal(t, 200, w.Code) | |
| 86 | + | require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link")) | |
| 87 | + | require.Equal(t, "2", w.Header().Get("Content-Length")) | |
| 88 | + | if method == "HEAD" { | |
| 89 | + | require.Empty(t, w.Body.String()) | |
| 90 | + | } else { | |
| 91 | + | require.Equal(t, "[]", w.Body.String()) | |
| 92 | + | } | |
| 93 | + | for _, tc := range []struct { | |
| 94 | + | path string | |
| 95 | + | ref ports.ReleaseRef | |
| 96 | + | }{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} { | |
| 97 | + | api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once() | |
| 98 | + | w = releaseRequest(t, h, method, tc.path, "", nil) | |
| 99 | + | require.Equal(t, 200, w.Code) | |
| 100 | + | } | |
| 101 | + | api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once() | |
| 102 | + | w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil) | |
| 103 | + | require.Equal(t, 200, w.Code) | |
| 104 | + | require.Equal(t, "asset-link", w.Header().Get("Link")) | |
| 105 | + | api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once() | |
| 106 | + | w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"}) | |
| 107 | + | require.Equal(t, 200, w.Code) | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 111 | + | h := New(Config{Releases: api}) | |
| 112 | + | b := []byte(`{"tag_name":"v1"}`) | |
| 113 | + | api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once() | |
| 114 | + | w := releaseRequest(t, h, "POST", releasePath, string(b), nil) | |
| 115 | + | require.Equal(t, 201, w.Code) | |
| 116 | + | require.Equal(t, `{"id":4}`, w.Body.String()) | |
| 117 | + | api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once() | |
| 118 | + | w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil) | |
| 119 | + | require.Equal(t, 200, w.Code) | |
| 120 | + | api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once() | |
| 121 | + | w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil) | |
| 122 | + | require.Equal(t, 200, w.Code) | |
| 123 | + | api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once() | |
| 124 | + | w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil) | |
| 125 | + | require.Equal(t, 204, w.Code) | |
| 126 | + | require.Empty(t, w.Body.String()) | |
| 127 | + | api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once() | |
| 128 | + | w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil) | |
| 129 | + | require.Equal(t, 204, w.Code) | |
| 130 | + | require.Empty(t, w.Body.String()) | |
| 131 | + | } | |
| 132 | + | func TestReleaseUpload(t *testing.T) { | |
| 133 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 134 | + | h := New(Config{Releases: api}) | |
| 135 | + | req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc")) | |
| 136 | + | req.ContentLength = -1 | |
| 137 | + | w := httptest.NewRecorder() | |
| 138 | + | h.ServeHTTP(w, req) | |
| 139 | + | require.Equal(t, 411, w.Code) | |
| 140 | + | require.Equal(t, `{"message":"Length Required"}`, w.Body.String()) | |
| 141 | + | // HTTP distinguishes an omitted length from an explicitly empty asset. | |
| 142 | + | w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil) | |
| 143 | + | require.Equal(t, 411, w.Code) | |
| 144 | + | api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once() | |
| 145 | + | w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"}) | |
| 146 | + | require.Equal(t, 201, w.Code) | |
| 147 | + | w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil) | |
| 148 | + | require.Equal(t, 422, w.Code) | |
| 149 | + | for _, ct := range []string{"", "text/plain"} { | |
| 150 | + | expectedType := ct | |
| 151 | + | if ct == "" { | |
| 152 | + | expectedType = "application/octet-stream" | |
| 153 | + | } | |
| 154 | + | api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) { | |
| 155 | + | got, err := io.ReadAll(r) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | require.Equal(t, "abc", string(got)) | |
| 158 | + | return []byte(`{"id":7}`), nil | |
| 159 | + | }).Once() | |
| 160 | + | req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef")) | |
| 161 | + | req.ContentLength = 3 | |
| 162 | + | req.Header.Set("Content-Type", ct) | |
| 163 | + | w = httptest.NewRecorder() | |
| 164 | + | h.ServeHTTP(w, req) | |
| 165 | + | require.Equal(t, 201, w.Code) | |
| 166 | + | require.Equal(t, `{"id":7}`, w.Body.String()) | |
| 167 | + | } | |
| 168 | + | // The real response template expands to precisely the upload route used above. | |
| 169 | + | b, err := os.ReadFile("testdata/releases/release.json") | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | var object struct { | |
| 172 | + | UploadURL string `json:"upload_url"` | |
| 173 | + | } | |
| 174 | + | require.NoError(t, json.Unmarshal(b, &object)) | |
| 175 | + | expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz") | |
| 176 | + | u, err := url.Parse(expanded) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix)) | |
| 179 | + | require.True(t, ok) | |
| 180 | + | require.Equal(t, "release-upload", kind) | |
| 181 | + | require.Equal(t, "acme", owner) | |
| 182 | + | require.Equal(t, "releases", repo) | |
| 183 | + | require.Equal(t, "1", ref) | |
| 184 | + | } | |
| 185 | + | func TestReleaseBodyLimit(t *testing.T) { | |
| 186 | + | h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)}) | |
| 187 | + | for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} { | |
| 188 | + | w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil) | |
| 189 | + | require.Equal(t, 422, w.Code) | |
| 190 | + | require.Equal(t, `{"message":"Body too large"}`, w.Body.String()) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | func TestReleaseErrors(t *testing.T) { | |
| 194 | + | rn, err := domain.ParseRepoName("proj") | |
| 195 | + | require.NoError(t, err) | |
| 196 | + | job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}}) | |
| 197 | + | cases := []struct { | |
| 198 | + | name, kind string | |
| 199 | + | p domain.Principal | |
| 200 | + | err error | |
| 201 | + | status int | |
| 202 | + | body string | |
| 203 | + | }{ | |
| 204 | + | {"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody}, | |
| 205 | + | {"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`}, | |
| 206 | + | {"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`}, | |
| 207 | + | {"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`}, | |
| 208 | + | {"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`}, | |
| 209 | + | {"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`}, | |
| 210 | + | {"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`}, | |
| 211 | + | {"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`}, | |
| 212 | + | {"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`}, | |
| 213 | + | {"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`}, | |
| 214 | + | {"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`}, | |
| 215 | + | {"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`}, | |
| 216 | + | {"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`}, | |
| 217 | + | {"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`}, | |
| 218 | + | } | |
| 219 | + | for _, tc := range cases { | |
| 220 | + | t.Run(tc.name, func(t *testing.T) { | |
| 221 | + | w := httptest.NewRecorder() | |
| 222 | + | New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "") | |
| 223 | + | require.Equal(t, tc.status, w.Code) | |
| 224 | + | require.Equal(t, tc.body, w.Body.String()) | |
| 225 | + | if tc.status == 429 { | |
| 226 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |
| 227 | + | } | |
| 228 | + | }) | |
| 229 | + | } | |
| 230 | + | // Verify the route actually forwards a use-case visibility failure unchanged. | |
| 231 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 232 | + | api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once() | |
| 233 | + | w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil) | |
| 234 | + | require.Equal(t, 404, w.Code) | |
| 235 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 236 | + | } | |
| 237 | + | func downloadAsset(t *testing.T) domain.ReleaseAsset { | |
| 238 | + | t.Helper() | |
| 239 | + | rn, err := domain.ParseRepoName("proj") | |
| 240 | + | require.NoError(t, err) | |
| 241 | + | return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)} | |
| 242 | + | } | |
| 243 | + | func TestReleaseDownload(t *testing.T) { | |
| 244 | + | cases := []struct { | |
| 245 | + | name, method, rang, etag string | |
| 246 | + | status int | |
| 247 | + | offset, length int64 | |
| 248 | + | body, contentRange string | |
| 249 | + | }{ | |
| 250 | + | {"whole", "GET", "", "", 200, 0, 10, "0123456789", ""}, | |
| 251 | + | {"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"}, | |
| 252 | + | {"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |
| 253 | + | {"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |
| 254 | + | {"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"}, | |
| 255 | + | {"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |
| 256 | + | {"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""}, | |
| 257 | + | {"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""}, | |
| 258 | + | {"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"}, | |
| 259 | + | {"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"}, | |
| 260 | + | {"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"}, | |
| 261 | + | {"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"}, | |
| 262 | + | {"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""}, | |
| 263 | + | {"head", "HEAD", "", "", 200, 0, 10, "", ""}, | |
| 264 | + | {"head range", "HEAD", "bytes=2-5", "", 206, 2, 4, "", "bytes 2-5/10"}, | |
| 265 | + | } | |
| 266 | + | for _, tc := range cases { | |
| 267 | + | t.Run(tc.name, func(t *testing.T) { | |
| 268 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 269 | + | dl := mocks.NewReleaseService(t) | |
| 270 | + | a := downloadAsset(t) | |
| 271 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 272 | + | if tc.method == "GET" && (tc.status == 200 || tc.status == 206) { | |
| 273 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error { | |
| 274 | + | require.NoError(t, send(ports.AssetChunk{Asset: &a})) | |
| 275 | + | return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]}) | |
| 276 | + | }).Once() | |
| 277 | + | } | |
| 278 | + | w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag}) | |
| 279 | + | require.Equal(t, tc.status, w.Code) | |
| 280 | + | require.Equal(t, tc.body, w.Body.String()) | |
| 281 | + | require.Equal(t, tc.contentRange, w.Header().Get("Content-Range")) | |
| 282 | + | require.Equal(t, "application/gzip", w.Header().Get("Content-Type")) | |
| 283 | + | require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition")) | |
| 284 | + | require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) | |
| 285 | + | require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy")) | |
| 286 | + | require.Equal(t, "bytes", w.Header().Get("Accept-Ranges")) | |
| 287 | + | require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag")) | |
| 288 | + | require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified")) | |
| 289 | + | if tc.status == 200 || tc.status == 206 { | |
| 290 | + | require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length")) | |
| 291 | + | } | |
| 292 | + | }) | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | func TestReleaseDownloadSafety(t *testing.T) { | |
| 296 | + | for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} { | |
| 297 | + | a := downloadAsset(t) | |
| 298 | + | a.ContentType = ct | |
| 299 | + | a.Name = "é space.txt" | |
| 300 | + | w := httptest.NewRecorder() | |
| 301 | + | setReleaseDownloadHeaders(w, a) | |
| 302 | + | require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type")) | |
| 303 | + | require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition")) | |
| 304 | + | } | |
| 305 | + | require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt")) | |
| 306 | + | } | |
| 307 | + | func TestReleaseDownloadFailures(t *testing.T) { | |
| 308 | + | for _, started := range []bool{false, true} { | |
| 309 | + | t.Run(fmt.Sprint(started), func(t *testing.T) { | |
| 310 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 311 | + | dl := mocks.NewReleaseService(t) | |
| 312 | + | a := downloadAsset(t) | |
| 313 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 314 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error { | |
| 315 | + | if started { | |
| 316 | + | require.NoError(t, send(ports.AssetChunk{Data: []byte("x")})) | |
| 317 | + | } | |
| 318 | + | return domain.ErrNotFound | |
| 319 | + | }).Once() | |
| 320 | + | h := New(Config{Releases: api, ReleaseDownload: dl}) | |
| 321 | + | run := func() { | |
| 322 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 323 | + | require.Equal(t, 404, w.Code) | |
| 324 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 325 | + | require.Empty(t, w.Header().Get("Content-Length")) | |
| 326 | + | require.Empty(t, w.Header().Get("Content-Range")) | |
| 327 | + | } | |
| 328 | + | if started { | |
| 329 | + | require.PanicsWithValue(t, http.ErrAbortHandler, run) | |
| 330 | + | } else { | |
| 331 | + | run() | |
| 332 | + | } | |
| 333 | + | require.Empty(t, h.archives) | |
| 334 | + | require.Empty(t, h.clients) | |
| 335 | + | }) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | func TestReleaseDownloadSlots(t *testing.T) { | |
| 339 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 340 | + | a := downloadAsset(t) | |
| 341 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2) | |
| 342 | + | h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1}) | |
| 343 | + | release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1") | |
| 344 | + | require.True(t, ok) | |
| 345 | + | defer release() | |
| 346 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 347 | + | require.Equal(t, 429, w.Code) | |
| 348 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |
| 349 | + | require.Empty(t, w.Header().Get("Content-Length")) | |
| 350 | + | require.Contains(t, w.Body.String(), "Too many archives") | |
| 351 | + | // A HEAD uses metadata, even when every download slot is taken. | |
| 352 | + | w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 353 | + | require.Equal(t, 200, w.Code) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestReleaseEmptyAndMissingDownload(t *testing.T) { | |
| 357 | + | for _, tc := range []struct { | |
| 358 | + | name string | |
| 359 | + | size int64 | |
| 360 | + | err error | |
| 361 | + | want int | |
| 362 | + | }{ | |
| 363 | + | {"empty", 0, domain.ErrOutOfRange, 200}, | |
| 364 | + | {"missing empty", 0, domain.ErrNotFound, 404}, | |
| 365 | + | {"store range", 10, domain.ErrOutOfRange, 416}, | |
| 366 | + | } { | |
| 367 | + | t.Run(tc.name, func(t *testing.T) { | |
| 368 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 369 | + | dl := mocks.NewReleaseService(t) | |
| 370 | + | a := downloadAsset(t) | |
| 371 | + | a.Size = tc.size | |
| 372 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 373 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once() | |
| 374 | + | w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 375 | + | require.Equal(t, tc.want, w.Code) | |
| 376 | + | switch tc.want { | |
| 377 | + | case 200: | |
| 378 | + | require.Equal(t, "0", w.Header().Get("Content-Length")) | |
| 379 | + | require.Empty(t, w.Body.String()) | |
| 380 | + | case 416: | |
| 381 | + | require.Equal(t, "bytes */10", w.Header().Get("Content-Range")) | |
| 382 | + | case 404: | |
| 383 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 384 | + | } | |
| 385 | + | }) | |
| 386 | + | } | |
| 387 | + | // Missing the stream service leaves JSON available but bytes disabled. | |
| 388 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 389 | + | w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 390 | + | require.Equal(t, 404, w.Code) | |
| 391 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func TestReleaseDownloadGlobalSlot(t *testing.T) { | |
| 395 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 396 | + | a := downloadAsset(t) | |
| 397 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 398 | + | h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1}) | |
| 399 | + | release, ok := h.acquire(httptest.NewRecorder(), "another-client") | |
| 400 | + | require.True(t, ok) | |
| 401 | + | defer release() | |
| 402 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 403 | + | require.Equal(t, 503, w.Code) | |
| 404 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |
| 405 | + | require.Empty(t, w.Header().Get("Content-Length")) | |
| 406 | + | require.Contains(t, w.Body.String(), "Server busy") | |
| 407 | + | require.NotContains(t, h.clients, "192.0.2.1") | |
| 408 | + | } | |
| @@ -0,0 +1,408 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package githubapi | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "net/http" | |||
| 12 | + | "net/http/httptest" | |||
| 13 | + | "net/url" | |||
| 14 | + | "os" | |||
| 15 | + | "strings" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 22 | + | "github.com/stretchr/testify/mock" | |||
| 23 | + | "github.com/stretchr/testify/require" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | const releasePath = "/repos/acme/proj/releases" | |||
| 27 | + | const releaseAssetPath = releasePath + "/assets/7" | |||
| 28 | + | const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets" | |||
| 29 | + | ||||
| 30 | + | func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { | |||
| 31 | + | t.Helper() | |||
| 32 | + | req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body)) | |||
| 33 | + | for k, v := range headers { | |||
| 34 | + | req.Header.Set(k, v) | |||
| 35 | + | } | |||
| 36 | + | w := httptest.NewRecorder() | |||
| 37 | + | h.ServeHTTP(w, req) | |||
| 38 | + | return w | |||
| 39 | + | } | |||
| 40 | + | func TestReleaseRoutes(t *testing.T) { | |||
| 41 | + | cases := []struct{ path, kind, ref, allow string }{ | |||
| 42 | + | {releasePath, "release-list", "", "GET, HEAD, POST"}, | |||
| 43 | + | {releasePath + "/latest", "release-latest", "", "GET, HEAD"}, | |||
| 44 | + | {releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"}, | |||
| 45 | + | {releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"}, | |||
| 46 | + | {releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"}, | |||
| 47 | + | {releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"}, | |||
| 48 | + | {releaseUploadPath, "release-upload", "4", "POST"}, | |||
| 49 | + | } | |||
| 50 | + | for _, tc := range cases { | |||
| 51 | + | t.Run(tc.kind, func(t *testing.T) { | |||
| 52 | + | kind, owner, repo, ref, ok := route(tc.path) | |||
| 53 | + | require.True(t, ok) | |||
| 54 | + | require.Equal(t, tc.kind, kind) | |||
| 55 | + | require.Equal(t, "acme", owner) | |||
| 56 | + | require.Equal(t, "proj", repo) | |||
| 57 | + | require.Equal(t, tc.ref, ref) | |||
| 58 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 59 | + | h := New(Config{Releases: api}) | |||
| 60 | + | for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} { | |||
| 61 | + | if methodAllowed(method, tc.allow) { | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 64 | + | w := releaseRequest(t, h, method, tc.path, "", nil) | |||
| 65 | + | require.Equal(t, 405, w.Code) | |||
| 66 | + | require.Equal(t, tc.allow, w.Header().Get("Allow")) | |||
| 67 | + | } | |||
| 68 | + | w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil) | |||
| 69 | + | require.Equal(t, 404, w.Code) | |||
| 70 | + | require.Equal(t, notFoundBody, w.Body.String()) | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} { | |||
| 74 | + | _, _, _, _, ok := route(path) | |||
| 75 | + | require.False(t, ok, path) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | func TestReleaseDispatch(t *testing.T) { | |||
| 79 | + | for _, method := range []string{"GET", "HEAD"} { | |||
| 80 | + | t.Run(method, func(t *testing.T) { | |||
| 81 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 82 | + | h := New(Config{Releases: api}) | |||
| 83 | + | api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once() | |||
| 84 | + | w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil) | |||
| 85 | + | require.Equal(t, 200, w.Code) | |||
| 86 | + | require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link")) | |||
| 87 | + | require.Equal(t, "2", w.Header().Get("Content-Length")) | |||
| 88 | + | if method == "HEAD" { | |||
| 89 | + | require.Empty(t, w.Body.String()) | |||
| 90 | + | } else { | |||
| 91 | + | require.Equal(t, "[]", w.Body.String()) | |||
| 92 | + | } | |||
| 93 | + | for _, tc := range []struct { | |||
| 94 | + | path string | |||
| 95 | + | ref ports.ReleaseRef | |||
| 96 | + | }{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} { | |||
| 97 | + | api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once() | |||
| 98 | + | w = releaseRequest(t, h, method, tc.path, "", nil) | |||
| 99 | + | require.Equal(t, 200, w.Code) | |||
| 100 | + | } | |||
| 101 | + | api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once() | |||
| 102 | + | w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil) | |||
| 103 | + | require.Equal(t, 200, w.Code) | |||
| 104 | + | require.Equal(t, "asset-link", w.Header().Get("Link")) | |||
| 105 | + | api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once() | |||
| 106 | + | w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"}) | |||
| 107 | + | require.Equal(t, 200, w.Code) | |||
| 108 | + | }) | |||
| 109 | + | } | |||
| 110 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 111 | + | h := New(Config{Releases: api}) | |||
| 112 | + | b := []byte(`{"tag_name":"v1"}`) | |||
| 113 | + | api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once() | |||
| 114 | + | w := releaseRequest(t, h, "POST", releasePath, string(b), nil) | |||
| 115 | + | require.Equal(t, 201, w.Code) | |||
| 116 | + | require.Equal(t, `{"id":4}`, w.Body.String()) | |||
| 117 | + | api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once() | |||
| 118 | + | w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil) | |||
| 119 | + | require.Equal(t, 200, w.Code) | |||
| 120 | + | api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once() | |||
| 121 | + | w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil) | |||
| 122 | + | require.Equal(t, 200, w.Code) | |||
| 123 | + | api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once() | |||
| 124 | + | w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil) | |||
| 125 | + | require.Equal(t, 204, w.Code) | |||
| 126 | + | require.Empty(t, w.Body.String()) | |||
| 127 | + | api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once() | |||
| 128 | + | w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil) | |||
| 129 | + | require.Equal(t, 204, w.Code) | |||
| 130 | + | require.Empty(t, w.Body.String()) | |||
| 131 | + | } | |||
| 132 | + | func TestReleaseUpload(t *testing.T) { | |||
| 133 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 134 | + | h := New(Config{Releases: api}) | |||
| 135 | + | req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc")) | |||
| 136 | + | req.ContentLength = -1 | |||
| 137 | + | w := httptest.NewRecorder() | |||
| 138 | + | h.ServeHTTP(w, req) | |||
| 139 | + | require.Equal(t, 411, w.Code) | |||
| 140 | + | require.Equal(t, `{"message":"Length Required"}`, w.Body.String()) | |||
| 141 | + | // HTTP distinguishes an omitted length from an explicitly empty asset. | |||
| 142 | + | w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil) | |||
| 143 | + | require.Equal(t, 411, w.Code) | |||
| 144 | + | api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once() | |||
| 145 | + | w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"}) | |||
| 146 | + | require.Equal(t, 201, w.Code) | |||
| 147 | + | w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil) | |||
| 148 | + | require.Equal(t, 422, w.Code) | |||
| 149 | + | for _, ct := range []string{"", "text/plain"} { | |||
| 150 | + | expectedType := ct | |||
| 151 | + | if ct == "" { | |||
| 152 | + | expectedType = "application/octet-stream" | |||
| 153 | + | } | |||
| 154 | + | api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) { | |||
| 155 | + | got, err := io.ReadAll(r) | |||
| 156 | + | require.NoError(t, err) | |||
| 157 | + | require.Equal(t, "abc", string(got)) | |||
| 158 | + | return []byte(`{"id":7}`), nil | |||
| 159 | + | }).Once() | |||
| 160 | + | req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef")) | |||
| 161 | + | req.ContentLength = 3 | |||
| 162 | + | req.Header.Set("Content-Type", ct) | |||
| 163 | + | w = httptest.NewRecorder() | |||
| 164 | + | h.ServeHTTP(w, req) | |||
| 165 | + | require.Equal(t, 201, w.Code) | |||
| 166 | + | require.Equal(t, `{"id":7}`, w.Body.String()) | |||
| 167 | + | } | |||
| 168 | + | // The real response template expands to precisely the upload route used above. | |||
| 169 | + | b, err := os.ReadFile("testdata/releases/release.json") | |||
| 170 | + | require.NoError(t, err) | |||
| 171 | + | var object struct { | |||
| 172 | + | UploadURL string `json:"upload_url"` | |||
| 173 | + | } | |||
| 174 | + | require.NoError(t, json.Unmarshal(b, &object)) | |||
| 175 | + | expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz") | |||
| 176 | + | u, err := url.Parse(expanded) | |||
| 177 | + | require.NoError(t, err) | |||
| 178 | + | kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix)) | |||
| 179 | + | require.True(t, ok) | |||
| 180 | + | require.Equal(t, "release-upload", kind) | |||
| 181 | + | require.Equal(t, "acme", owner) | |||
| 182 | + | require.Equal(t, "releases", repo) | |||
| 183 | + | require.Equal(t, "1", ref) | |||
| 184 | + | } | |||
| 185 | + | func TestReleaseBodyLimit(t *testing.T) { | |||
| 186 | + | h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)}) | |||
| 187 | + | for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} { | |||
| 188 | + | w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil) | |||
| 189 | + | require.Equal(t, 422, w.Code) | |||
| 190 | + | require.Equal(t, `{"message":"Body too large"}`, w.Body.String()) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | func TestReleaseErrors(t *testing.T) { | |||
| 194 | + | rn, err := domain.ParseRepoName("proj") | |||
| 195 | + | require.NoError(t, err) | |||
| 196 | + | job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}}) | |||
| 197 | + | cases := []struct { | |||
| 198 | + | name, kind string | |||
| 199 | + | p domain.Principal | |||
| 200 | + | err error | |||
| 201 | + | status int | |||
| 202 | + | body string | |||
| 203 | + | }{ | |||
| 204 | + | {"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody}, | |||
| 205 | + | {"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`}, | |||
| 206 | + | {"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`}, | |||
| 207 | + | {"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`}, | |||
| 208 | + | {"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`}, | |||
| 209 | + | {"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`}, | |||
| 210 | + | {"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`}, | |||
| 211 | + | {"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`}, | |||
| 212 | + | {"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`}, | |||
| 213 | + | {"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`}, | |||
| 214 | + | {"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`}, | |||
| 215 | + | {"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`}, | |||
| 216 | + | {"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`}, | |||
| 217 | + | {"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`}, | |||
| 218 | + | } | |||
| 219 | + | for _, tc := range cases { | |||
| 220 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 221 | + | w := httptest.NewRecorder() | |||
| 222 | + | New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "") | |||
| 223 | + | require.Equal(t, tc.status, w.Code) | |||
| 224 | + | require.Equal(t, tc.body, w.Body.String()) | |||
| 225 | + | if tc.status == 429 { | |||
| 226 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |||
| 227 | + | } | |||
| 228 | + | }) | |||
| 229 | + | } | |||
| 230 | + | // Verify the route actually forwards a use-case visibility failure unchanged. | |||
| 231 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 232 | + | api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once() | |||
| 233 | + | w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil) | |||
| 234 | + | require.Equal(t, 404, w.Code) | |||
| 235 | + | require.Equal(t, notFoundBody, w.Body.String()) | |||
| 236 | + | } | |||
| 237 | + | func downloadAsset(t *testing.T) domain.ReleaseAsset { | |||
| 238 | + | t.Helper() | |||
| 239 | + | rn, err := domain.ParseRepoName("proj") | |||
| 240 | + | require.NoError(t, err) | |||
| 241 | + | return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)} | |||
| 242 | + | } | |||
| 243 | + | func TestReleaseDownload(t *testing.T) { | |||
| 244 | + | cases := []struct { | |||
| 245 | + | name, method, rang, etag string | |||
| 246 | + | status int | |||
| 247 | + | offset, length int64 | |||
| 248 | + | body, contentRange string | |||
| 249 | + | }{ | |||
| 250 | + | {"whole", "GET", "", "", 200, 0, 10, "0123456789", ""}, | |||
| 251 | + | {"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"}, | |||
| 252 | + | {"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |||
| 253 | + | {"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |||
| 254 | + | {"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"}, | |||
| 255 | + | {"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |||
| 256 | + | {"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""}, | |||
| 257 | + | {"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""}, | |||
| 258 | + | {"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"}, | |||
| 259 | + | {"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"}, | |||
| 260 | + | {"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"}, | |||
| 261 | + | {"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"}, | |||
| 262 | + | {"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""}, | |||
| 263 | + | {"head", "HEAD", "", "", 200, 0, 10, "", ""}, | |||
| 264 | + | {"head range", "HEAD", "bytes=2-5", "", 206, 2, 4, "", "bytes 2-5/10"}, | |||
| 265 | + | } | |||
| 266 | + | for _, tc := range cases { | |||
| 267 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 268 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 269 | + | dl := mocks.NewReleaseService(t) | |||
| 270 | + | a := downloadAsset(t) | |||
| 271 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |||
| 272 | + | if tc.method == "GET" && (tc.status == 200 || tc.status == 206) { | |||
| 273 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error { | |||
| 274 | + | require.NoError(t, send(ports.AssetChunk{Asset: &a})) | |||
| 275 | + | return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]}) | |||
| 276 | + | }).Once() | |||
| 277 | + | } | |||
| 278 | + | w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag}) | |||
| 279 | + | require.Equal(t, tc.status, w.Code) | |||
| 280 | + | require.Equal(t, tc.body, w.Body.String()) | |||
| 281 | + | require.Equal(t, tc.contentRange, w.Header().Get("Content-Range")) | |||
| 282 | + | require.Equal(t, "application/gzip", w.Header().Get("Content-Type")) | |||
| 283 | + | require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition")) | |||
| 284 | + | require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) | |||
| 285 | + | require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy")) | |||
| 286 | + | require.Equal(t, "bytes", w.Header().Get("Accept-Ranges")) | |||
| 287 | + | require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag")) | |||
| 288 | + | require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified")) | |||
| 289 | + | if tc.status == 200 || tc.status == 206 { | |||
| 290 | + | require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length")) | |||
| 291 | + | } | |||
| 292 | + | }) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | func TestReleaseDownloadSafety(t *testing.T) { | |||
| 296 | + | for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} { | |||
| 297 | + | a := downloadAsset(t) | |||
| 298 | + | a.ContentType = ct | |||
| 299 | + | a.Name = "é space.txt" | |||
| 300 | + | w := httptest.NewRecorder() | |||
| 301 | + | setReleaseDownloadHeaders(w, a) | |||
| 302 | + | require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type")) | |||
| 303 | + | require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition")) | |||
| 304 | + | } | |||
| 305 | + | require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt")) | |||
| 306 | + | } | |||
| 307 | + | func TestReleaseDownloadFailures(t *testing.T) { | |||
| 308 | + | for _, started := range []bool{false, true} { | |||
| 309 | + | t.Run(fmt.Sprint(started), func(t *testing.T) { | |||
| 310 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 311 | + | dl := mocks.NewReleaseService(t) | |||
| 312 | + | a := downloadAsset(t) | |||
| 313 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |||
| 314 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error { | |||
| 315 | + | if started { | |||
| 316 | + | require.NoError(t, send(ports.AssetChunk{Data: []byte("x")})) | |||
| 317 | + | } | |||
| 318 | + | return domain.ErrNotFound | |||
| 319 | + | }).Once() | |||
| 320 | + | h := New(Config{Releases: api, ReleaseDownload: dl}) | |||
| 321 | + | run := func() { | |||
| 322 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |||
| 323 | + | require.Equal(t, 404, w.Code) | |||
| 324 | + | require.Equal(t, notFoundBody, w.Body.String()) | |||
| 325 | + | require.Empty(t, w.Header().Get("Content-Length")) | |||
| 326 | + | require.Empty(t, w.Header().Get("Content-Range")) | |||
| 327 | + | } | |||
| 328 | + | if started { | |||
| 329 | + | require.PanicsWithValue(t, http.ErrAbortHandler, run) | |||
| 330 | + | } else { | |||
| 331 | + | run() | |||
| 332 | + | } | |||
| 333 | + | require.Empty(t, h.archives) | |||
| 334 | + | require.Empty(t, h.clients) | |||
| 335 | + | }) | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | func TestReleaseDownloadSlots(t *testing.T) { | |||
| 339 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 340 | + | a := downloadAsset(t) | |||
| 341 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2) | |||
| 342 | + | h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1}) | |||
| 343 | + | release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1") | |||
| 344 | + | require.True(t, ok) | |||
| 345 | + | defer release() | |||
| 346 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |||
| 347 | + | require.Equal(t, 429, w.Code) | |||
| 348 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |||
| 349 | + | require.Empty(t, w.Header().Get("Content-Length")) | |||
| 350 | + | require.Contains(t, w.Body.String(), "Too many archives") | |||
| 351 | + | // A HEAD uses metadata, even when every download slot is taken. | |||
| 352 | + | w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |||
| 353 | + | require.Equal(t, 200, w.Code) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestReleaseEmptyAndMissingDownload(t *testing.T) { | |||
| 357 | + | for _, tc := range []struct { | |||
| 358 | + | name string | |||
| 359 | + | size int64 | |||
| 360 | + | err error | |||
| 361 | + | want int | |||
| 362 | + | }{ | |||
| 363 | + | {"empty", 0, domain.ErrOutOfRange, 200}, | |||
| 364 | + | {"missing empty", 0, domain.ErrNotFound, 404}, | |||
| 365 | + | {"store range", 10, domain.ErrOutOfRange, 416}, | |||
| 366 | + | } { | |||
| 367 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 368 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 369 | + | dl := mocks.NewReleaseService(t) | |||
| 370 | + | a := downloadAsset(t) | |||
| 371 | + | a.Size = tc.size | |||
| 372 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |||
| 373 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once() | |||
| 374 | + | w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |||
| 375 | + | require.Equal(t, tc.want, w.Code) | |||
| 376 | + | switch tc.want { | |||
| 377 | + | case 200: | |||
| 378 | + | require.Equal(t, "0", w.Header().Get("Content-Length")) | |||
| 379 | + | require.Empty(t, w.Body.String()) | |||
| 380 | + | case 416: | |||
| 381 | + | require.Equal(t, "bytes */10", w.Header().Get("Content-Range")) | |||
| 382 | + | case 404: | |||
| 383 | + | require.Equal(t, notFoundBody, w.Body.String()) | |||
| 384 | + | } | |||
| 385 | + | }) | |||
| 386 | + | } | |||
| 387 | + | // Missing the stream service leaves JSON available but bytes disabled. | |||
| 388 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 389 | + | w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |||
| 390 | + | require.Equal(t, 404, w.Code) | |||
| 391 | + | require.Equal(t, notFoundBody, w.Body.String()) | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func TestReleaseDownloadGlobalSlot(t *testing.T) { | |||
| 395 | + | api := mocks.NewGitHubReleaseAPI(t) | |||
| 396 | + | a := downloadAsset(t) | |||
| 397 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |||
| 398 | + | h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1}) | |||
| 399 | + | release, ok := h.acquire(httptest.NewRecorder(), "another-client") | |||
| 400 | + | require.True(t, ok) | |||
| 401 | + | defer release() | |||
| 402 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |||
| 403 | + | require.Equal(t, 503, w.Code) | |||
| 404 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |||
| 405 | + | require.Empty(t, w.Header().Get("Content-Length")) | |||
| 406 | + | require.Contains(t, w.Body.String(), "Server busy") | |||
| 407 | + | require.NotContains(t, h.clients, "192.0.2.1") | |||
| 408 | + | } | |||
A internal/adapters/githubapi/testdata/releases/draft.json +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | { | |
| 2 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4", | |
| 3 | + | "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4/assets", | |
| 4 | + | "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/4/assets{?name,label}", | |
| 5 | + | "html_url": "http://localhost:7341/releases/releases/tag/next", | |
| 6 | + | "id": 4, | |
| 7 | + | "author": { | |
| 8 | + | "avatar_url": "", | |
| 9 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |
| 10 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |
| 11 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |
| 12 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |
| 13 | + | "gravatar_id": "", | |
| 14 | + | "html_url": "http://localhost:7341/acme", | |
| 15 | + | "id": 13112345534056, | |
| 16 | + | "login": "acme", | |
| 17 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |
| 18 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |
| 19 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |
| 20 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |
| 21 | + | "site_admin": false, | |
| 22 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |
| 23 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |
| 24 | + | "type": "User", | |
| 25 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |
| 26 | + | "user_view_type": "public" | |
| 27 | + | }, | |
| 28 | + | "node_id": "", | |
| 29 | + | "tag_name": "next", | |
| 30 | + | "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |
| 31 | + | "name": "", | |
| 32 | + | "draft": true, | |
| 33 | + | "prerelease": false, | |
| 34 | + | "created_at": "2026-10-10T08:00:00Z", | |
| 35 | + | "published_at": null, | |
| 36 | + | "assets": [], | |
| 37 | + | "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/next", | |
| 38 | + | "zipball_url": null, | |
| 39 | + | "body": "" | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | { | |||
| 2 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4", | |||
| 3 | + | "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4/assets", | |||
| 4 | + | "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/4/assets{?name,label}", | |||
| 5 | + | "html_url": "http://localhost:7341/releases/releases/tag/next", | |||
| 6 | + | "id": 4, | |||
| 7 | + | "author": { | |||
| 8 | + | "avatar_url": "", | |||
| 9 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |||
| 10 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |||
| 11 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |||
| 12 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |||
| 13 | + | "gravatar_id": "", | |||
| 14 | + | "html_url": "http://localhost:7341/acme", | |||
| 15 | + | "id": 13112345534056, | |||
| 16 | + | "login": "acme", | |||
| 17 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |||
| 18 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |||
| 19 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |||
| 20 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |||
| 21 | + | "site_admin": false, | |||
| 22 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |||
| 23 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |||
| 24 | + | "type": "User", | |||
| 25 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |||
| 26 | + | "user_view_type": "public" | |||
| 27 | + | }, | |||
| 28 | + | "node_id": "", | |||
| 29 | + | "tag_name": "next", | |||
| 30 | + | "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |||
| 31 | + | "name": "", | |||
| 32 | + | "draft": true, | |||
| 33 | + | "prerelease": false, | |||
| 34 | + | "created_at": "2026-10-10T08:00:00Z", | |||
| 35 | + | "published_at": null, | |||
| 36 | + | "assets": [], | |||
| 37 | + | "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/next", | |||
| 38 | + | "zipball_url": null, | |||
| 39 | + | "body": "" | |||
| 40 | + | } | |||
A internal/adapters/githubapi/testdata/releases/release.json +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | { | |
| 2 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1", | |
| 3 | + | "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1/assets", | |
| 4 | + | "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/1/assets{?name,label}", | |
| 5 | + | "html_url": "http://localhost:7341/releases/releases/tag/v1", | |
| 6 | + | "id": 1, | |
| 7 | + | "author": { | |
| 8 | + | "avatar_url": "", | |
| 9 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |
| 10 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |
| 11 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |
| 12 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |
| 13 | + | "gravatar_id": "", | |
| 14 | + | "html_url": "http://localhost:7341/acme", | |
| 15 | + | "id": 13112345534056, | |
| 16 | + | "login": "acme", | |
| 17 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |
| 18 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |
| 19 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |
| 20 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |
| 21 | + | "site_admin": false, | |
| 22 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |
| 23 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |
| 24 | + | "type": "User", | |
| 25 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |
| 26 | + | "user_view_type": "public" | |
| 27 | + | }, | |
| 28 | + | "node_id": "", | |
| 29 | + | "tag_name": "v1", | |
| 30 | + | "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |
| 31 | + | "name": "", | |
| 32 | + | "draft": false, | |
| 33 | + | "prerelease": false, | |
| 34 | + | "created_at": "2026-10-10T08:00:00Z", | |
| 35 | + | "published_at": "2026-10-10T08:00:00Z", | |
| 36 | + | "assets": [ | |
| 37 | + | { | |
| 38 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/3", | |
| 39 | + | "id": 3, | |
| 40 | + | "node_id": "", | |
| 41 | + | "name": "checksums.txt", | |
| 42 | + | "label": "", | |
| 43 | + | "uploader": { | |
| 44 | + | "avatar_url": "", | |
| 45 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |
| 46 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |
| 47 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |
| 48 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |
| 49 | + | "gravatar_id": "", | |
| 50 | + | "html_url": "http://localhost:7341/acme", | |
| 51 | + | "id": 13112345534056, | |
| 52 | + | "login": "acme", | |
| 53 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |
| 54 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |
| 55 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |
| 56 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |
| 57 | + | "site_admin": false, | |
| 58 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |
| 59 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |
| 60 | + | "type": "User", | |
| 61 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |
| 62 | + | "user_view_type": "public" | |
| 63 | + | }, | |
| 64 | + | "content_type": "application/octet-stream", | |
| 65 | + | "state": "uploaded", | |
| 66 | + | "size": 3, | |
| 67 | + | "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", | |
| 68 | + | "download_count": 0, | |
| 69 | + | "created_at": "2026-10-10T08:00:00Z", | |
| 70 | + | "updated_at": "2026-10-10T08:00:00Z", | |
| 71 | + | "browser_download_url": "http://localhost:7341/releases/releases/download/v1/checksums.txt" | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/2", | |
| 75 | + | "id": 2, | |
| 76 | + | "node_id": "", | |
| 77 | + | "name": "ermite.tar.gz", | |
| 78 | + | "label": "", | |
| 79 | + | "uploader": { | |
| 80 | + | "avatar_url": "", | |
| 81 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |
| 82 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |
| 83 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |
| 84 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |
| 85 | + | "gravatar_id": "", | |
| 86 | + | "html_url": "http://localhost:7341/acme", | |
| 87 | + | "id": 13112345534056, | |
| 88 | + | "login": "acme", | |
| 89 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |
| 90 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |
| 91 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |
| 92 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |
| 93 | + | "site_admin": false, | |
| 94 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |
| 95 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |
| 96 | + | "type": "User", | |
| 97 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |
| 98 | + | "user_view_type": "public" | |
| 99 | + | }, | |
| 100 | + | "content_type": "application/octet-stream", | |
| 101 | + | "state": "uploaded", | |
| 102 | + | "size": 3, | |
| 103 | + | "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", | |
| 104 | + | "download_count": 0, | |
| 105 | + | "created_at": "2026-10-10T08:00:00Z", | |
| 106 | + | "updated_at": "2026-10-10T08:00:00Z", | |
| 107 | + | "browser_download_url": "http://localhost:7341/releases/releases/download/v1/ermite.tar.gz" | |
| 108 | + | } | |
| 109 | + | ], | |
| 110 | + | "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/v1", | |
| 111 | + | "zipball_url": null, | |
| 112 | + | "body": "" | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | { | |||
| 2 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1", | |||
| 3 | + | "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1/assets", | |||
| 4 | + | "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/1/assets{?name,label}", | |||
| 5 | + | "html_url": "http://localhost:7341/releases/releases/tag/v1", | |||
| 6 | + | "id": 1, | |||
| 7 | + | "author": { | |||
| 8 | + | "avatar_url": "", | |||
| 9 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |||
| 10 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |||
| 11 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |||
| 12 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |||
| 13 | + | "gravatar_id": "", | |||
| 14 | + | "html_url": "http://localhost:7341/acme", | |||
| 15 | + | "id": 13112345534056, | |||
| 16 | + | "login": "acme", | |||
| 17 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |||
| 18 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |||
| 19 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |||
| 20 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |||
| 21 | + | "site_admin": false, | |||
| 22 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |||
| 23 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |||
| 24 | + | "type": "User", | |||
| 25 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |||
| 26 | + | "user_view_type": "public" | |||
| 27 | + | }, | |||
| 28 | + | "node_id": "", | |||
| 29 | + | "tag_name": "v1", | |||
| 30 | + | "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |||
| 31 | + | "name": "", | |||
| 32 | + | "draft": false, | |||
| 33 | + | "prerelease": false, | |||
| 34 | + | "created_at": "2026-10-10T08:00:00Z", | |||
| 35 | + | "published_at": "2026-10-10T08:00:00Z", | |||
| 36 | + | "assets": [ | |||
| 37 | + | { | |||
| 38 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/3", | |||
| 39 | + | "id": 3, | |||
| 40 | + | "node_id": "", | |||
| 41 | + | "name": "checksums.txt", | |||
| 42 | + | "label": "", | |||
| 43 | + | "uploader": { | |||
| 44 | + | "avatar_url": "", | |||
| 45 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |||
| 46 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |||
| 47 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |||
| 48 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |||
| 49 | + | "gravatar_id": "", | |||
| 50 | + | "html_url": "http://localhost:7341/acme", | |||
| 51 | + | "id": 13112345534056, | |||
| 52 | + | "login": "acme", | |||
| 53 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |||
| 54 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |||
| 55 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |||
| 56 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |||
| 57 | + | "site_admin": false, | |||
| 58 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |||
| 59 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |||
| 60 | + | "type": "User", | |||
| 61 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |||
| 62 | + | "user_view_type": "public" | |||
| 63 | + | }, | |||
| 64 | + | "content_type": "application/octet-stream", | |||
| 65 | + | "state": "uploaded", | |||
| 66 | + | "size": 3, | |||
| 67 | + | "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", | |||
| 68 | + | "download_count": 0, | |||
| 69 | + | "created_at": "2026-10-10T08:00:00Z", | |||
| 70 | + | "updated_at": "2026-10-10T08:00:00Z", | |||
| 71 | + | "browser_download_url": "http://localhost:7341/releases/releases/download/v1/checksums.txt" | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/2", | |||
| 75 | + | "id": 2, | |||
| 76 | + | "node_id": "", | |||
| 77 | + | "name": "ermite.tar.gz", | |||
| 78 | + | "label": "", | |||
| 79 | + | "uploader": { | |||
| 80 | + | "avatar_url": "", | |||
| 81 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |||
| 82 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |||
| 83 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |||
| 84 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |||
| 85 | + | "gravatar_id": "", | |||
| 86 | + | "html_url": "http://localhost:7341/acme", | |||
| 87 | + | "id": 13112345534056, | |||
| 88 | + | "login": "acme", | |||
| 89 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |||
| 90 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |||
| 91 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |||
| 92 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |||
| 93 | + | "site_admin": false, | |||
| 94 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |||
| 95 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |||
| 96 | + | "type": "User", | |||
| 97 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |||
| 98 | + | "user_view_type": "public" | |||
| 99 | + | }, | |||
| 100 | + | "content_type": "application/octet-stream", | |||
| 101 | + | "state": "uploaded", | |||
| 102 | + | "size": 3, | |||
| 103 | + | "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", | |||
| 104 | + | "download_count": 0, | |||
| 105 | + | "created_at": "2026-10-10T08:00:00Z", | |||
| 106 | + | "updated_at": "2026-10-10T08:00:00Z", | |||
| 107 | + | "browser_download_url": "http://localhost:7341/releases/releases/download/v1/ermite.tar.gz" | |||
| 108 | + | } | |||
| 109 | + | ], | |||
| 110 | + | "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/v1", | |||
| 111 | + | "zipball_url": null, | |||
| 112 | + | "body": "" | |||
| 113 | + | } | |||
A internal/ports/mocks/GitHubReleaseAPI.go +1044 -0
| @@ -0,0 +1,1044 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | mock "github.com/stretchr/testify/mock" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // NewGitHubReleaseAPI creates a new instance of GitHubReleaseAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 17 | + | // The first argument is typically a *testing.T value. | |
| 18 | + | func NewGitHubReleaseAPI(t interface { | |
| 19 | + | mock.TestingT | |
| 20 | + | Cleanup(func()) | |
| 21 | + | }) *GitHubReleaseAPI { | |
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 23 | + | helper.Helper() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | mock := &GitHubReleaseAPI{} | |
| 27 | + | mock.Mock.Test(t) | |
| 28 | + | ||
| 29 | + | t.Cleanup(func() { | |
| 30 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 31 | + | helper.Helper() | |
| 32 | + | } | |
| 33 | + | mock.AssertExpectations(t) | |
| 34 | + | }) | |
| 35 | + | ||
| 36 | + | return mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // GitHubReleaseAPI is an autogenerated mock type for the GitHubReleaseAPI type | |
| 40 | + | type GitHubReleaseAPI struct { | |
| 41 | + | mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type GitHubReleaseAPI_Expecter struct { | |
| 45 | + | mock *mock.Mock | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (_m *GitHubReleaseAPI) EXPECT() *GitHubReleaseAPI_Expecter { | |
| 49 | + | return &GitHubReleaseAPI_Expecter{mock: &_m.Mock} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // AssetByID provides a mock function for the type GitHubReleaseAPI | |
| 53 | + | func (_mock *GitHubReleaseAPI) AssetByID(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error) { | |
| 54 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |
| 55 | + | ||
| 56 | + | if len(ret) == 0 { | |
| 57 | + | panic("no return value specified for AssetByID") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | var r0 domain.ReleaseAsset | |
| 61 | + | var r1 error | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) (domain.ReleaseAsset, error)); ok { | |
| 63 | + | return returnFunc(ctx, p, owner, repo, assetID) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) domain.ReleaseAsset); ok { | |
| 66 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |
| 67 | + | } else { | |
| 68 | + | r0 = ret.Get(0).(domain.ReleaseAsset) | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, p, owner, repo, assetID) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // GitHubReleaseAPI_AssetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetByID' | |
| 79 | + | type GitHubReleaseAPI_AssetByID_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // AssetByID is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - p domain.Principal | |
| 86 | + | // - owner string | |
| 87 | + | // - repo string | |
| 88 | + | // - assetID int64 | |
| 89 | + | func (_e *GitHubReleaseAPI_Expecter) AssetByID(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_AssetByID_Call { | |
| 90 | + | return &GitHubReleaseAPI_AssetByID_Call{Call: _e.mock.On("AssetByID", ctx, p, owner, repo, assetID)} | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_AssetByID_Call { | |
| 94 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 95 | + | var arg0 context.Context | |
| 96 | + | if args[0] != nil { | |
| 97 | + | arg0 = args[0].(context.Context) | |
| 98 | + | } | |
| 99 | + | var arg1 domain.Principal | |
| 100 | + | if args[1] != nil { | |
| 101 | + | arg1 = args[1].(domain.Principal) | |
| 102 | + | } | |
| 103 | + | var arg2 string | |
| 104 | + | if args[2] != nil { | |
| 105 | + | arg2 = args[2].(string) | |
| 106 | + | } | |
| 107 | + | var arg3 string | |
| 108 | + | if args[3] != nil { | |
| 109 | + | arg3 = args[3].(string) | |
| 110 | + | } | |
| 111 | + | var arg4 int64 | |
| 112 | + | if args[4] != nil { | |
| 113 | + | arg4 = args[4].(int64) | |
| 114 | + | } | |
| 115 | + | run( | |
| 116 | + | arg0, | |
| 117 | + | arg1, | |
| 118 | + | arg2, | |
| 119 | + | arg3, | |
| 120 | + | arg4, | |
| 121 | + | ) | |
| 122 | + | }) | |
| 123 | + | return _c | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) Return(releaseAsset domain.ReleaseAsset, err error) *GitHubReleaseAPI_AssetByID_Call { | |
| 127 | + | _c.Call.Return(releaseAsset, err) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error)) *GitHubReleaseAPI_AssetByID_Call { | |
| 132 | + | _c.Call.Return(run) | |
| 133 | + | return _c | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // CreateRelease provides a mock function for the type GitHubReleaseAPI | |
| 137 | + | func (_mock *GitHubReleaseAPI) CreateRelease(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error) { | |
| 138 | + | ret := _mock.Called(ctx, p, owner, repo, body) | |
| 139 | + | ||
| 140 | + | if len(ret) == 0 { | |
| 141 | + | panic("no return value specified for CreateRelease") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | var r0 []byte | |
| 145 | + | var r1 error | |
| 146 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) ([]byte, error)); ok { | |
| 147 | + | return returnFunc(ctx, p, owner, repo, body) | |
| 148 | + | } | |
| 149 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) []byte); ok { | |
| 150 | + | r0 = returnFunc(ctx, p, owner, repo, body) | |
| 151 | + | } else { | |
| 152 | + | if ret.Get(0) != nil { | |
| 153 | + | r0 = ret.Get(0).([]byte) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, []byte) error); ok { | |
| 157 | + | r1 = returnFunc(ctx, p, owner, repo, body) | |
| 158 | + | } else { | |
| 159 | + | r1 = ret.Error(1) | |
| 160 | + | } | |
| 161 | + | return r0, r1 | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // GitHubReleaseAPI_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease' | |
| 165 | + | type GitHubReleaseAPI_CreateRelease_Call struct { | |
| 166 | + | *mock.Call | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // CreateRelease is a helper method to define mock.On call | |
| 170 | + | // - ctx context.Context | |
| 171 | + | // - p domain.Principal | |
| 172 | + | // - owner string | |
| 173 | + | // - repo string | |
| 174 | + | // - body []byte | |
| 175 | + | func (_e *GitHubReleaseAPI_Expecter) CreateRelease(ctx any, p any, owner any, repo any, body any) *GitHubReleaseAPI_CreateRelease_Call { | |
| 176 | + | return &GitHubReleaseAPI_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, p, owner, repo, body)} | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte)) *GitHubReleaseAPI_CreateRelease_Call { | |
| 180 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 181 | + | var arg0 context.Context | |
| 182 | + | if args[0] != nil { | |
| 183 | + | arg0 = args[0].(context.Context) | |
| 184 | + | } | |
| 185 | + | var arg1 domain.Principal | |
| 186 | + | if args[1] != nil { | |
| 187 | + | arg1 = args[1].(domain.Principal) | |
| 188 | + | } | |
| 189 | + | var arg2 string | |
| 190 | + | if args[2] != nil { | |
| 191 | + | arg2 = args[2].(string) | |
| 192 | + | } | |
| 193 | + | var arg3 string | |
| 194 | + | if args[3] != nil { | |
| 195 | + | arg3 = args[3].(string) | |
| 196 | + | } | |
| 197 | + | var arg4 []byte | |
| 198 | + | if args[4] != nil { | |
| 199 | + | arg4 = args[4].([]byte) | |
| 200 | + | } | |
| 201 | + | run( | |
| 202 | + | arg0, | |
| 203 | + | arg1, | |
| 204 | + | arg2, | |
| 205 | + | arg3, | |
| 206 | + | arg4, | |
| 207 | + | ) | |
| 208 | + | }) | |
| 209 | + | return _c | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_CreateRelease_Call { | |
| 213 | + | _c.Call.Return(bytes, err) | |
| 214 | + | return _c | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error)) *GitHubReleaseAPI_CreateRelease_Call { | |
| 218 | + | _c.Call.Return(run) | |
| 219 | + | return _c | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // DeleteAsset provides a mock function for the type GitHubReleaseAPI | |
| 223 | + | func (_mock *GitHubReleaseAPI) DeleteAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error { | |
| 224 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |
| 225 | + | ||
| 226 | + | if len(ret) == 0 { | |
| 227 | + | panic("no return value specified for DeleteAsset") | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | var r0 error | |
| 231 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |
| 232 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |
| 233 | + | } else { | |
| 234 | + | r0 = ret.Error(0) | |
| 235 | + | } | |
| 236 | + | return r0 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // GitHubReleaseAPI_DeleteAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAsset' | |
| 240 | + | type GitHubReleaseAPI_DeleteAsset_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // DeleteAsset is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - p domain.Principal | |
| 247 | + | // - owner string | |
| 248 | + | // - repo string | |
| 249 | + | // - assetID int64 | |
| 250 | + | func (_e *GitHubReleaseAPI_Expecter) DeleteAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_DeleteAsset_Call { | |
| 251 | + | return &GitHubReleaseAPI_DeleteAsset_Call{Call: _e.mock.On("DeleteAsset", ctx, p, owner, repo, assetID)} | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_DeleteAsset_Call { | |
| 255 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 256 | + | var arg0 context.Context | |
| 257 | + | if args[0] != nil { | |
| 258 | + | arg0 = args[0].(context.Context) | |
| 259 | + | } | |
| 260 | + | var arg1 domain.Principal | |
| 261 | + | if args[1] != nil { | |
| 262 | + | arg1 = args[1].(domain.Principal) | |
| 263 | + | } | |
| 264 | + | var arg2 string | |
| 265 | + | if args[2] != nil { | |
| 266 | + | arg2 = args[2].(string) | |
| 267 | + | } | |
| 268 | + | var arg3 string | |
| 269 | + | if args[3] != nil { | |
| 270 | + | arg3 = args[3].(string) | |
| 271 | + | } | |
| 272 | + | var arg4 int64 | |
| 273 | + | if args[4] != nil { | |
| 274 | + | arg4 = args[4].(int64) | |
| 275 | + | } | |
| 276 | + | run( | |
| 277 | + | arg0, | |
| 278 | + | arg1, | |
| 279 | + | arg2, | |
| 280 | + | arg3, | |
| 281 | + | arg4, | |
| 282 | + | ) | |
| 283 | + | }) | |
| 284 | + | return _c | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) Return(err error) *GitHubReleaseAPI_DeleteAsset_Call { | |
| 288 | + | _c.Call.Return(err) | |
| 289 | + | return _c | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error) *GitHubReleaseAPI_DeleteAsset_Call { | |
| 293 | + | _c.Call.Return(run) | |
| 294 | + | return _c | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // DeleteRelease provides a mock function for the type GitHubReleaseAPI | |
| 298 | + | func (_mock *GitHubReleaseAPI) DeleteRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error { | |
| 299 | + | ret := _mock.Called(ctx, p, owner, repo, id) | |
| 300 | + | ||
| 301 | + | if len(ret) == 0 { | |
| 302 | + | panic("no return value specified for DeleteRelease") | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | var r0 error | |
| 306 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |
| 307 | + | r0 = returnFunc(ctx, p, owner, repo, id) | |
| 308 | + | } else { | |
| 309 | + | r0 = ret.Error(0) | |
| 310 | + | } | |
| 311 | + | return r0 | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // GitHubReleaseAPI_DeleteRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRelease' | |
| 315 | + | type GitHubReleaseAPI_DeleteRelease_Call struct { | |
| 316 | + | *mock.Call | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // DeleteRelease is a helper method to define mock.On call | |
| 320 | + | // - ctx context.Context | |
| 321 | + | // - p domain.Principal | |
| 322 | + | // - owner string | |
| 323 | + | // - repo string | |
| 324 | + | // - id int64 | |
| 325 | + | func (_e *GitHubReleaseAPI_Expecter) DeleteRelease(ctx any, p any, owner any, repo any, id any) *GitHubReleaseAPI_DeleteRelease_Call { | |
| 326 | + | return &GitHubReleaseAPI_DeleteRelease_Call{Call: _e.mock.On("DeleteRelease", ctx, p, owner, repo, id)} | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64)) *GitHubReleaseAPI_DeleteRelease_Call { | |
| 330 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 331 | + | var arg0 context.Context | |
| 332 | + | if args[0] != nil { | |
| 333 | + | arg0 = args[0].(context.Context) | |
| 334 | + | } | |
| 335 | + | var arg1 domain.Principal | |
| 336 | + | if args[1] != nil { | |
| 337 | + | arg1 = args[1].(domain.Principal) | |
| 338 | + | } | |
| 339 | + | var arg2 string | |
| 340 | + | if args[2] != nil { | |
| 341 | + | arg2 = args[2].(string) | |
| 342 | + | } | |
| 343 | + | var arg3 string | |
| 344 | + | if args[3] != nil { | |
| 345 | + | arg3 = args[3].(string) | |
| 346 | + | } | |
| 347 | + | var arg4 int64 | |
| 348 | + | if args[4] != nil { | |
| 349 | + | arg4 = args[4].(int64) | |
| 350 | + | } | |
| 351 | + | run( | |
| 352 | + | arg0, | |
| 353 | + | arg1, | |
| 354 | + | arg2, | |
| 355 | + | arg3, | |
| 356 | + | arg4, | |
| 357 | + | ) | |
| 358 | + | }) | |
| 359 | + | return _c | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) Return(err error) *GitHubReleaseAPI_DeleteRelease_Call { | |
| 363 | + | _c.Call.Return(err) | |
| 364 | + | return _c | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error) *GitHubReleaseAPI_DeleteRelease_Call { | |
| 368 | + | _c.Call.Return(run) | |
| 369 | + | return _c | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // GetAsset provides a mock function for the type GitHubReleaseAPI | |
| 373 | + | func (_mock *GitHubReleaseAPI) GetAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error) { | |
| 374 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |
| 375 | + | ||
| 376 | + | if len(ret) == 0 { | |
| 377 | + | panic("no return value specified for GetAsset") | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | var r0 []byte | |
| 381 | + | var r1 error | |
| 382 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) ([]byte, error)); ok { | |
| 383 | + | return returnFunc(ctx, p, owner, repo, assetID) | |
| 384 | + | } | |
| 385 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) []byte); ok { | |
| 386 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |
| 387 | + | } else { | |
| 388 | + | if ret.Get(0) != nil { | |
| 389 | + | r0 = ret.Get(0).([]byte) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |
| 393 | + | r1 = returnFunc(ctx, p, owner, repo, assetID) | |
| 394 | + | } else { | |
| 395 | + | r1 = ret.Error(1) | |
| 396 | + | } | |
| 397 | + | return r0, r1 | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // GitHubReleaseAPI_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset' | |
| 401 | + | type GitHubReleaseAPI_GetAsset_Call struct { | |
| 402 | + | *mock.Call | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // GetAsset is a helper method to define mock.On call | |
| 406 | + | // - ctx context.Context | |
| 407 | + | // - p domain.Principal | |
| 408 | + | // - owner string | |
| 409 | + | // - repo string | |
| 410 | + | // - assetID int64 | |
| 411 | + | func (_e *GitHubReleaseAPI_Expecter) GetAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_GetAsset_Call { | |
| 412 | + | return &GitHubReleaseAPI_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, p, owner, repo, assetID)} | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_GetAsset_Call { | |
| 416 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 417 | + | var arg0 context.Context | |
| 418 | + | if args[0] != nil { | |
| 419 | + | arg0 = args[0].(context.Context) | |
| 420 | + | } | |
| 421 | + | var arg1 domain.Principal | |
| 422 | + | if args[1] != nil { | |
| 423 | + | arg1 = args[1].(domain.Principal) | |
| 424 | + | } | |
| 425 | + | var arg2 string | |
| 426 | + | if args[2] != nil { | |
| 427 | + | arg2 = args[2].(string) | |
| 428 | + | } | |
| 429 | + | var arg3 string | |
| 430 | + | if args[3] != nil { | |
| 431 | + | arg3 = args[3].(string) | |
| 432 | + | } | |
| 433 | + | var arg4 int64 | |
| 434 | + | if args[4] != nil { | |
| 435 | + | arg4 = args[4].(int64) | |
| 436 | + | } | |
| 437 | + | run( | |
| 438 | + | arg0, | |
| 439 | + | arg1, | |
| 440 | + | arg2, | |
| 441 | + | arg3, | |
| 442 | + | arg4, | |
| 443 | + | ) | |
| 444 | + | }) | |
| 445 | + | return _c | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetAsset_Call { | |
| 449 | + | _c.Call.Return(bytes, err) | |
| 450 | + | return _c | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error)) *GitHubReleaseAPI_GetAsset_Call { | |
| 454 | + | _c.Call.Return(run) | |
| 455 | + | return _c | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // GetRelease provides a mock function for the type GitHubReleaseAPI | |
| 459 | + | func (_mock *GitHubReleaseAPI) GetRelease(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error) { | |
| 460 | + | ret := _mock.Called(ctx, p, owner, repo, ref) | |
| 461 | + | ||
| 462 | + | if len(ret) == 0 { | |
| 463 | + | panic("no return value specified for GetRelease") | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | var r0 []byte | |
| 467 | + | var r1 error | |
| 468 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) ([]byte, error)); ok { | |
| 469 | + | return returnFunc(ctx, p, owner, repo, ref) | |
| 470 | + | } | |
| 471 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) []byte); ok { | |
| 472 | + | r0 = returnFunc(ctx, p, owner, repo, ref) | |
| 473 | + | } else { | |
| 474 | + | if ret.Get(0) != nil { | |
| 475 | + | r0 = ret.Get(0).([]byte) | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) error); ok { | |
| 479 | + | r1 = returnFunc(ctx, p, owner, repo, ref) | |
| 480 | + | } else { | |
| 481 | + | r1 = ret.Error(1) | |
| 482 | + | } | |
| 483 | + | return r0, r1 | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | // GitHubReleaseAPI_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease' | |
| 487 | + | type GitHubReleaseAPI_GetRelease_Call struct { | |
| 488 | + | *mock.Call | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // GetRelease is a helper method to define mock.On call | |
| 492 | + | // - ctx context.Context | |
| 493 | + | // - p domain.Principal | |
| 494 | + | // - owner string | |
| 495 | + | // - repo string | |
| 496 | + | // - ref ports.ReleaseRef | |
| 497 | + | func (_e *GitHubReleaseAPI_Expecter) GetRelease(ctx any, p any, owner any, repo any, ref any) *GitHubReleaseAPI_GetRelease_Call { | |
| 498 | + | return &GitHubReleaseAPI_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, p, owner, repo, ref)} | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef)) *GitHubReleaseAPI_GetRelease_Call { | |
| 502 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 503 | + | var arg0 context.Context | |
| 504 | + | if args[0] != nil { | |
| 505 | + | arg0 = args[0].(context.Context) | |
| 506 | + | } | |
| 507 | + | var arg1 domain.Principal | |
| 508 | + | if args[1] != nil { | |
| 509 | + | arg1 = args[1].(domain.Principal) | |
| 510 | + | } | |
| 511 | + | var arg2 string | |
| 512 | + | if args[2] != nil { | |
| 513 | + | arg2 = args[2].(string) | |
| 514 | + | } | |
| 515 | + | var arg3 string | |
| 516 | + | if args[3] != nil { | |
| 517 | + | arg3 = args[3].(string) | |
| 518 | + | } | |
| 519 | + | var arg4 ports.ReleaseRef | |
| 520 | + | if args[4] != nil { | |
| 521 | + | arg4 = args[4].(ports.ReleaseRef) | |
| 522 | + | } | |
| 523 | + | run( | |
| 524 | + | arg0, | |
| 525 | + | arg1, | |
| 526 | + | arg2, | |
| 527 | + | arg3, | |
| 528 | + | arg4, | |
| 529 | + | ) | |
| 530 | + | }) | |
| 531 | + | return _c | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetRelease_Call { | |
| 535 | + | _c.Call.Return(bytes, err) | |
| 536 | + | return _c | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error)) *GitHubReleaseAPI_GetRelease_Call { | |
| 540 | + | _c.Call.Return(run) | |
| 541 | + | return _c | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | // ListAssets provides a mock function for the type GitHubReleaseAPI | |
| 545 | + | func (_mock *GitHubReleaseAPI) ListAssets(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error) { | |
| 546 | + | ret := _mock.Called(ctx, p, owner, repo, id, page, perPage) | |
| 547 | + | ||
| 548 | + | if len(ret) == 0 { | |
| 549 | + | panic("no return value specified for ListAssets") | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | var r0 []byte | |
| 553 | + | var r1 string | |
| 554 | + | var r2 error | |
| 555 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) ([]byte, string, error)); ok { | |
| 556 | + | return returnFunc(ctx, p, owner, repo, id, page, perPage) | |
| 557 | + | } | |
| 558 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) []byte); ok { | |
| 559 | + | r0 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |
| 560 | + | } else { | |
| 561 | + | if ret.Get(0) != nil { | |
| 562 | + | r0 = ret.Get(0).([]byte) | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, int, int) string); ok { | |
| 566 | + | r1 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |
| 567 | + | } else { | |
| 568 | + | r1 = ret.Get(1).(string) | |
| 569 | + | } | |
| 570 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int64, int, int) error); ok { | |
| 571 | + | r2 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |
| 572 | + | } else { | |
| 573 | + | r2 = ret.Error(2) | |
| 574 | + | } | |
| 575 | + | return r0, r1, r2 | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // GitHubReleaseAPI_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets' | |
| 579 | + | type GitHubReleaseAPI_ListAssets_Call struct { | |
| 580 | + | *mock.Call | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | // ListAssets is a helper method to define mock.On call | |
| 584 | + | // - ctx context.Context | |
| 585 | + | // - p domain.Principal | |
| 586 | + | // - owner string | |
| 587 | + | // - repo string | |
| 588 | + | // - id int64 | |
| 589 | + | // - page int | |
| 590 | + | // - perPage int | |
| 591 | + | func (_e *GitHubReleaseAPI_Expecter) ListAssets(ctx any, p any, owner any, repo any, id any, page any, perPage any) *GitHubReleaseAPI_ListAssets_Call { | |
| 592 | + | return &GitHubReleaseAPI_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, p, owner, repo, id, page, perPage)} | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int)) *GitHubReleaseAPI_ListAssets_Call { | |
| 596 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 597 | + | var arg0 context.Context | |
| 598 | + | if args[0] != nil { | |
| 599 | + | arg0 = args[0].(context.Context) | |
| 600 | + | } | |
| 601 | + | var arg1 domain.Principal | |
| 602 | + | if args[1] != nil { | |
| 603 | + | arg1 = args[1].(domain.Principal) | |
| 604 | + | } | |
| 605 | + | var arg2 string | |
| 606 | + | if args[2] != nil { | |
| 607 | + | arg2 = args[2].(string) | |
| 608 | + | } | |
| 609 | + | var arg3 string | |
| 610 | + | if args[3] != nil { | |
| 611 | + | arg3 = args[3].(string) | |
| 612 | + | } | |
| 613 | + | var arg4 int64 | |
| 614 | + | if args[4] != nil { | |
| 615 | + | arg4 = args[4].(int64) | |
| 616 | + | } | |
| 617 | + | var arg5 int | |
| 618 | + | if args[5] != nil { | |
| 619 | + | arg5 = args[5].(int) | |
| 620 | + | } | |
| 621 | + | var arg6 int | |
| 622 | + | if args[6] != nil { | |
| 623 | + | arg6 = args[6].(int) | |
| 624 | + | } | |
| 625 | + | run( | |
| 626 | + | arg0, | |
| 627 | + | arg1, | |
| 628 | + | arg2, | |
| 629 | + | arg3, | |
| 630 | + | arg4, | |
| 631 | + | arg5, | |
| 632 | + | arg6, | |
| 633 | + | ) | |
| 634 | + | }) | |
| 635 | + | return _c | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListAssets_Call { | |
| 639 | + | _c.Call.Return(body, link, err) | |
| 640 | + | return _c | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListAssets_Call { | |
| 644 | + | _c.Call.Return(run) | |
| 645 | + | return _c | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | // ListReleases provides a mock function for the type GitHubReleaseAPI | |
| 649 | + | func (_mock *GitHubReleaseAPI) ListReleases(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error) { | |
| 650 | + | ret := _mock.Called(ctx, p, owner, repo, page, perPage) | |
| 651 | + | ||
| 652 | + | if len(ret) == 0 { | |
| 653 | + | panic("no return value specified for ListReleases") | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | var r0 []byte | |
| 657 | + | var r1 string | |
| 658 | + | var r2 error | |
| 659 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) ([]byte, string, error)); ok { | |
| 660 | + | return returnFunc(ctx, p, owner, repo, page, perPage) | |
| 661 | + | } | |
| 662 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) []byte); ok { | |
| 663 | + | r0 = returnFunc(ctx, p, owner, repo, page, perPage) | |
| 664 | + | } else { | |
| 665 | + | if ret.Get(0) != nil { | |
| 666 | + | r0 = ret.Get(0).([]byte) | |
| 667 | + | } | |
| 668 | + | } | |
| 669 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int, int) string); ok { | |
| 670 | + | r1 = returnFunc(ctx, p, owner, repo, page, perPage) | |
| 671 | + | } else { | |
| 672 | + | r1 = ret.Get(1).(string) | |
| 673 | + | } | |
| 674 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int, int) error); ok { | |
| 675 | + | r2 = returnFunc(ctx, p, owner, repo, page, perPage) | |
| 676 | + | } else { | |
| 677 | + | r2 = ret.Error(2) | |
| 678 | + | } | |
| 679 | + | return r0, r1, r2 | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | // GitHubReleaseAPI_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases' | |
| 683 | + | type GitHubReleaseAPI_ListReleases_Call struct { | |
| 684 | + | *mock.Call | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | // ListReleases is a helper method to define mock.On call | |
| 688 | + | // - ctx context.Context | |
| 689 | + | // - p domain.Principal | |
| 690 | + | // - owner string | |
| 691 | + | // - repo string | |
| 692 | + | // - page int | |
| 693 | + | // - perPage int | |
| 694 | + | func (_e *GitHubReleaseAPI_Expecter) ListReleases(ctx any, p any, owner any, repo any, page any, perPage any) *GitHubReleaseAPI_ListReleases_Call { | |
| 695 | + | return &GitHubReleaseAPI_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, p, owner, repo, page, perPage)} | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int)) *GitHubReleaseAPI_ListReleases_Call { | |
| 699 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 700 | + | var arg0 context.Context | |
| 701 | + | if args[0] != nil { | |
| 702 | + | arg0 = args[0].(context.Context) | |
| 703 | + | } | |
| 704 | + | var arg1 domain.Principal | |
| 705 | + | if args[1] != nil { | |
| 706 | + | arg1 = args[1].(domain.Principal) | |
| 707 | + | } | |
| 708 | + | var arg2 string | |
| 709 | + | if args[2] != nil { | |
| 710 | + | arg2 = args[2].(string) | |
| 711 | + | } | |
| 712 | + | var arg3 string | |
| 713 | + | if args[3] != nil { | |
| 714 | + | arg3 = args[3].(string) | |
| 715 | + | } | |
| 716 | + | var arg4 int | |
| 717 | + | if args[4] != nil { | |
| 718 | + | arg4 = args[4].(int) | |
| 719 | + | } | |
| 720 | + | var arg5 int | |
| 721 | + | if args[5] != nil { | |
| 722 | + | arg5 = args[5].(int) | |
| 723 | + | } | |
| 724 | + | run( | |
| 725 | + | arg0, | |
| 726 | + | arg1, | |
| 727 | + | arg2, | |
| 728 | + | arg3, | |
| 729 | + | arg4, | |
| 730 | + | arg5, | |
| 731 | + | ) | |
| 732 | + | }) | |
| 733 | + | return _c | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListReleases_Call { | |
| 737 | + | _c.Call.Return(body, link, err) | |
| 738 | + | return _c | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListReleases_Call { | |
| 742 | + | _c.Call.Return(run) | |
| 743 | + | return _c | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | // UpdateAsset provides a mock function for the type GitHubReleaseAPI | |
| 747 | + | func (_mock *GitHubReleaseAPI) UpdateAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error) { | |
| 748 | + | ret := _mock.Called(ctx, p, owner, repo, assetID, body) | |
| 749 | + | ||
| 750 | + | if len(ret) == 0 { | |
| 751 | + | panic("no return value specified for UpdateAsset") | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | var r0 []byte | |
| 755 | + | var r1 error | |
| 756 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok { | |
| 757 | + | return returnFunc(ctx, p, owner, repo, assetID, body) | |
| 758 | + | } | |
| 759 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok { | |
| 760 | + | r0 = returnFunc(ctx, p, owner, repo, assetID, body) | |
| 761 | + | } else { | |
| 762 | + | if ret.Get(0) != nil { | |
| 763 | + | r0 = ret.Get(0).([]byte) | |
| 764 | + | } | |
| 765 | + | } | |
| 766 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok { | |
| 767 | + | r1 = returnFunc(ctx, p, owner, repo, assetID, body) | |
| 768 | + | } else { | |
| 769 | + | r1 = ret.Error(1) | |
| 770 | + | } | |
| 771 | + | return r0, r1 | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | // GitHubReleaseAPI_UpdateAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAsset' | |
| 775 | + | type GitHubReleaseAPI_UpdateAsset_Call struct { | |
| 776 | + | *mock.Call | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | // UpdateAsset is a helper method to define mock.On call | |
| 780 | + | // - ctx context.Context | |
| 781 | + | // - p domain.Principal | |
| 782 | + | // - owner string | |
| 783 | + | // - repo string | |
| 784 | + | // - assetID int64 | |
| 785 | + | // - body []byte | |
| 786 | + | func (_e *GitHubReleaseAPI_Expecter) UpdateAsset(ctx any, p any, owner any, repo any, assetID any, body any) *GitHubReleaseAPI_UpdateAsset_Call { | |
| 787 | + | return &GitHubReleaseAPI_UpdateAsset_Call{Call: _e.mock.On("UpdateAsset", ctx, p, owner, repo, assetID, body)} | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte)) *GitHubReleaseAPI_UpdateAsset_Call { | |
| 791 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 792 | + | var arg0 context.Context | |
| 793 | + | if args[0] != nil { | |
| 794 | + | arg0 = args[0].(context.Context) | |
| 795 | + | } | |
| 796 | + | var arg1 domain.Principal | |
| 797 | + | if args[1] != nil { | |
| 798 | + | arg1 = args[1].(domain.Principal) | |
| 799 | + | } | |
| 800 | + | var arg2 string | |
| 801 | + | if args[2] != nil { | |
| 802 | + | arg2 = args[2].(string) | |
| 803 | + | } | |
| 804 | + | var arg3 string | |
| 805 | + | if args[3] != nil { | |
| 806 | + | arg3 = args[3].(string) | |
| 807 | + | } | |
| 808 | + | var arg4 int64 | |
| 809 | + | if args[4] != nil { | |
| 810 | + | arg4 = args[4].(int64) | |
| 811 | + | } | |
| 812 | + | var arg5 []byte | |
| 813 | + | if args[5] != nil { | |
| 814 | + | arg5 = args[5].([]byte) | |
| 815 | + | } | |
| 816 | + | run( | |
| 817 | + | arg0, | |
| 818 | + | arg1, | |
| 819 | + | arg2, | |
| 820 | + | arg3, | |
| 821 | + | arg4, | |
| 822 | + | arg5, | |
| 823 | + | ) | |
| 824 | + | }) | |
| 825 | + | return _c | |
| 826 | + | } | |
| 827 | + | ||
| 828 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateAsset_Call { | |
| 829 | + | _c.Call.Return(bytes, err) | |
| 830 | + | return _c | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateAsset_Call { | |
| 834 | + | _c.Call.Return(run) | |
| 835 | + | return _c | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | // UpdateRelease provides a mock function for the type GitHubReleaseAPI | |
| 839 | + | func (_mock *GitHubReleaseAPI) UpdateRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error) { | |
| 840 | + | ret := _mock.Called(ctx, p, owner, repo, id, body) | |
| 841 | + | ||
| 842 | + | if len(ret) == 0 { | |
| 843 | + | panic("no return value specified for UpdateRelease") | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | var r0 []byte | |
| 847 | + | var r1 error | |
| 848 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok { | |
| 849 | + | return returnFunc(ctx, p, owner, repo, id, body) | |
| 850 | + | } | |
| 851 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok { | |
| 852 | + | r0 = returnFunc(ctx, p, owner, repo, id, body) | |
| 853 | + | } else { | |
| 854 | + | if ret.Get(0) != nil { | |
| 855 | + | r0 = ret.Get(0).([]byte) | |
| 856 | + | } | |
| 857 | + | } | |
| 858 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok { | |
| 859 | + | r1 = returnFunc(ctx, p, owner, repo, id, body) | |
| 860 | + | } else { | |
| 861 | + | r1 = ret.Error(1) | |
| 862 | + | } | |
| 863 | + | return r0, r1 | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | // GitHubReleaseAPI_UpdateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRelease' | |
| 867 | + | type GitHubReleaseAPI_UpdateRelease_Call struct { | |
| 868 | + | *mock.Call | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | // UpdateRelease is a helper method to define mock.On call | |
| 872 | + | // - ctx context.Context | |
| 873 | + | // - p domain.Principal | |
| 874 | + | // - owner string | |
| 875 | + | // - repo string | |
| 876 | + | // - id int64 | |
| 877 | + | // - body []byte | |
| 878 | + | func (_e *GitHubReleaseAPI_Expecter) UpdateRelease(ctx any, p any, owner any, repo any, id any, body any) *GitHubReleaseAPI_UpdateRelease_Call { | |
| 879 | + | return &GitHubReleaseAPI_UpdateRelease_Call{Call: _e.mock.On("UpdateRelease", ctx, p, owner, repo, id, body)} | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte)) *GitHubReleaseAPI_UpdateRelease_Call { | |
| 883 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 884 | + | var arg0 context.Context | |
| 885 | + | if args[0] != nil { | |
| 886 | + | arg0 = args[0].(context.Context) | |
| 887 | + | } | |
| 888 | + | var arg1 domain.Principal | |
| 889 | + | if args[1] != nil { | |
| 890 | + | arg1 = args[1].(domain.Principal) | |
| 891 | + | } | |
| 892 | + | var arg2 string | |
| 893 | + | if args[2] != nil { | |
| 894 | + | arg2 = args[2].(string) | |
| 895 | + | } | |
| 896 | + | var arg3 string | |
| 897 | + | if args[3] != nil { | |
| 898 | + | arg3 = args[3].(string) | |
| 899 | + | } | |
| 900 | + | var arg4 int64 | |
| 901 | + | if args[4] != nil { | |
| 902 | + | arg4 = args[4].(int64) | |
| 903 | + | } | |
| 904 | + | var arg5 []byte | |
| 905 | + | if args[5] != nil { | |
| 906 | + | arg5 = args[5].([]byte) | |
| 907 | + | } | |
| 908 | + | run( | |
| 909 | + | arg0, | |
| 910 | + | arg1, | |
| 911 | + | arg2, | |
| 912 | + | arg3, | |
| 913 | + | arg4, | |
| 914 | + | arg5, | |
| 915 | + | ) | |
| 916 | + | }) | |
| 917 | + | return _c | |
| 918 | + | } | |
| 919 | + | ||
| 920 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateRelease_Call { | |
| 921 | + | _c.Call.Return(bytes, err) | |
| 922 | + | return _c | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateRelease_Call { | |
| 926 | + | _c.Call.Return(run) | |
| 927 | + | return _c | |
| 928 | + | } | |
| 929 | + | ||
| 930 | + | // UploadAsset provides a mock function for the type GitHubReleaseAPI | |
| 931 | + | func (_mock *GitHubReleaseAPI) UploadAsset(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error) { | |
| 932 | + | ret := _mock.Called(ctx, p, owner, repo, id, name, label, contentType, size, r) | |
| 933 | + | ||
| 934 | + | if len(ret) == 0 { | |
| 935 | + | panic("no return value specified for UploadAsset") | |
| 936 | + | } | |
| 937 | + | ||
| 938 | + | var r0 []byte | |
| 939 | + | var r1 error | |
| 940 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) ([]byte, error)); ok { | |
| 941 | + | return returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |
| 942 | + | } | |
| 943 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) []byte); ok { | |
| 944 | + | r0 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |
| 945 | + | } else { | |
| 946 | + | if ret.Get(0) != nil { | |
| 947 | + | r0 = ret.Get(0).([]byte) | |
| 948 | + | } | |
| 949 | + | } | |
| 950 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) error); ok { | |
| 951 | + | r1 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |
| 952 | + | } else { | |
| 953 | + | r1 = ret.Error(1) | |
| 954 | + | } | |
| 955 | + | return r0, r1 | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | // GitHubReleaseAPI_UploadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadAsset' | |
| 959 | + | type GitHubReleaseAPI_UploadAsset_Call struct { | |
| 960 | + | *mock.Call | |
| 961 | + | } | |
| 962 | + | ||
| 963 | + | // UploadAsset is a helper method to define mock.On call | |
| 964 | + | // - ctx context.Context | |
| 965 | + | // - p domain.Principal | |
| 966 | + | // - owner string | |
| 967 | + | // - repo string | |
| 968 | + | // - id int64 | |
| 969 | + | // - name string | |
| 970 | + | // - label string | |
| 971 | + | // - contentType string | |
| 972 | + | // - size int64 | |
| 973 | + | // - r io.Reader | |
| 974 | + | func (_e *GitHubReleaseAPI_Expecter) UploadAsset(ctx any, p any, owner any, repo any, id any, name any, label any, contentType any, size any, r any) *GitHubReleaseAPI_UploadAsset_Call { | |
| 975 | + | return &GitHubReleaseAPI_UploadAsset_Call{Call: _e.mock.On("UploadAsset", ctx, p, owner, repo, id, name, label, contentType, size, r)} | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader)) *GitHubReleaseAPI_UploadAsset_Call { | |
| 979 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 980 | + | var arg0 context.Context | |
| 981 | + | if args[0] != nil { | |
| 982 | + | arg0 = args[0].(context.Context) | |
| 983 | + | } | |
| 984 | + | var arg1 domain.Principal | |
| 985 | + | if args[1] != nil { | |
| 986 | + | arg1 = args[1].(domain.Principal) | |
| 987 | + | } | |
| 988 | + | var arg2 string | |
| 989 | + | if args[2] != nil { | |
| 990 | + | arg2 = args[2].(string) | |
| 991 | + | } | |
| 992 | + | var arg3 string | |
| 993 | + | if args[3] != nil { | |
| 994 | + | arg3 = args[3].(string) | |
| 995 | + | } | |
| 996 | + | var arg4 int64 | |
| 997 | + | if args[4] != nil { | |
| 998 | + | arg4 = args[4].(int64) | |
| 999 | + | } | |
| 1000 | + | var arg5 string | |
| 1001 | + | if args[5] != nil { | |
| 1002 | + | arg5 = args[5].(string) | |
| 1003 | + | } | |
| 1004 | + | var arg6 string | |
| 1005 | + | if args[6] != nil { | |
| 1006 | + | arg6 = args[6].(string) | |
| 1007 | + | } | |
| 1008 | + | var arg7 string | |
| 1009 | + | if args[7] != nil { | |
| 1010 | + | arg7 = args[7].(string) | |
| 1011 | + | } | |
| 1012 | + | var arg8 int64 | |
| 1013 | + | if args[8] != nil { | |
| 1014 | + | arg8 = args[8].(int64) | |
| 1015 | + | } | |
| 1016 | + | var arg9 io.Reader | |
| 1017 | + | if args[9] != nil { | |
| 1018 | + | arg9 = args[9].(io.Reader) | |
| 1019 | + | } | |
| 1020 | + | run( | |
| 1021 | + | arg0, | |
| 1022 | + | arg1, | |
| 1023 | + | arg2, | |
| 1024 | + | arg3, | |
| 1025 | + | arg4, | |
| 1026 | + | arg5, | |
| 1027 | + | arg6, | |
| 1028 | + | arg7, | |
| 1029 | + | arg8, | |
| 1030 | + | arg9, | |
| 1031 | + | ) | |
| 1032 | + | }) | |
| 1033 | + | return _c | |
| 1034 | + | } | |
| 1035 | + | ||
| 1036 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UploadAsset_Call { | |
| 1037 | + | _c.Call.Return(bytes, err) | |
| 1038 | + | return _c | |
| 1039 | + | } | |
| 1040 | + | ||
| 1041 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error)) *GitHubReleaseAPI_UploadAsset_Call { | |
| 1042 | + | _c.Call.Return(run) | |
| 1043 | + | return _c | |
| 1044 | + | } | |
| @@ -0,0 +1,1044 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "io" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | "bnema.dev/ermite/internal/ports" | |||
| 13 | + | mock "github.com/stretchr/testify/mock" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // NewGitHubReleaseAPI creates a new instance of GitHubReleaseAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 17 | + | // The first argument is typically a *testing.T value. | |||
| 18 | + | func NewGitHubReleaseAPI(t interface { | |||
| 19 | + | mock.TestingT | |||
| 20 | + | Cleanup(func()) | |||
| 21 | + | }) *GitHubReleaseAPI { | |||
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 23 | + | helper.Helper() | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | mock := &GitHubReleaseAPI{} | |||
| 27 | + | mock.Mock.Test(t) | |||
| 28 | + | ||||
| 29 | + | t.Cleanup(func() { | |||
| 30 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 31 | + | helper.Helper() | |||
| 32 | + | } | |||
| 33 | + | mock.AssertExpectations(t) | |||
| 34 | + | }) | |||
| 35 | + | ||||
| 36 | + | return mock | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // GitHubReleaseAPI is an autogenerated mock type for the GitHubReleaseAPI type | |||
| 40 | + | type GitHubReleaseAPI struct { | |||
| 41 | + | mock.Mock | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type GitHubReleaseAPI_Expecter struct { | |||
| 45 | + | mock *mock.Mock | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (_m *GitHubReleaseAPI) EXPECT() *GitHubReleaseAPI_Expecter { | |||
| 49 | + | return &GitHubReleaseAPI_Expecter{mock: &_m.Mock} | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // AssetByID provides a mock function for the type GitHubReleaseAPI | |||
| 53 | + | func (_mock *GitHubReleaseAPI) AssetByID(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error) { | |||
| 54 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |||
| 55 | + | ||||
| 56 | + | if len(ret) == 0 { | |||
| 57 | + | panic("no return value specified for AssetByID") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | var r0 domain.ReleaseAsset | |||
| 61 | + | var r1 error | |||
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) (domain.ReleaseAsset, error)); ok { | |||
| 63 | + | return returnFunc(ctx, p, owner, repo, assetID) | |||
| 64 | + | } | |||
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) domain.ReleaseAsset); ok { | |||
| 66 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |||
| 67 | + | } else { | |||
| 68 | + | r0 = ret.Get(0).(domain.ReleaseAsset) | |||
| 69 | + | } | |||
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |||
| 71 | + | r1 = returnFunc(ctx, p, owner, repo, assetID) | |||
| 72 | + | } else { | |||
| 73 | + | r1 = ret.Error(1) | |||
| 74 | + | } | |||
| 75 | + | return r0, r1 | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // GitHubReleaseAPI_AssetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetByID' | |||
| 79 | + | type GitHubReleaseAPI_AssetByID_Call struct { | |||
| 80 | + | *mock.Call | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // AssetByID is a helper method to define mock.On call | |||
| 84 | + | // - ctx context.Context | |||
| 85 | + | // - p domain.Principal | |||
| 86 | + | // - owner string | |||
| 87 | + | // - repo string | |||
| 88 | + | // - assetID int64 | |||
| 89 | + | func (_e *GitHubReleaseAPI_Expecter) AssetByID(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_AssetByID_Call { | |||
| 90 | + | return &GitHubReleaseAPI_AssetByID_Call{Call: _e.mock.On("AssetByID", ctx, p, owner, repo, assetID)} | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_AssetByID_Call { | |||
| 94 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 95 | + | var arg0 context.Context | |||
| 96 | + | if args[0] != nil { | |||
| 97 | + | arg0 = args[0].(context.Context) | |||
| 98 | + | } | |||
| 99 | + | var arg1 domain.Principal | |||
| 100 | + | if args[1] != nil { | |||
| 101 | + | arg1 = args[1].(domain.Principal) | |||
| 102 | + | } | |||
| 103 | + | var arg2 string | |||
| 104 | + | if args[2] != nil { | |||
| 105 | + | arg2 = args[2].(string) | |||
| 106 | + | } | |||
| 107 | + | var arg3 string | |||
| 108 | + | if args[3] != nil { | |||
| 109 | + | arg3 = args[3].(string) | |||
| 110 | + | } | |||
| 111 | + | var arg4 int64 | |||
| 112 | + | if args[4] != nil { | |||
| 113 | + | arg4 = args[4].(int64) | |||
| 114 | + | } | |||
| 115 | + | run( | |||
| 116 | + | arg0, | |||
| 117 | + | arg1, | |||
| 118 | + | arg2, | |||
| 119 | + | arg3, | |||
| 120 | + | arg4, | |||
| 121 | + | ) | |||
| 122 | + | }) | |||
| 123 | + | return _c | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) Return(releaseAsset domain.ReleaseAsset, err error) *GitHubReleaseAPI_AssetByID_Call { | |||
| 127 | + | _c.Call.Return(releaseAsset, err) | |||
| 128 | + | return _c | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error)) *GitHubReleaseAPI_AssetByID_Call { | |||
| 132 | + | _c.Call.Return(run) | |||
| 133 | + | return _c | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // CreateRelease provides a mock function for the type GitHubReleaseAPI | |||
| 137 | + | func (_mock *GitHubReleaseAPI) CreateRelease(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error) { | |||
| 138 | + | ret := _mock.Called(ctx, p, owner, repo, body) | |||
| 139 | + | ||||
| 140 | + | if len(ret) == 0 { | |||
| 141 | + | panic("no return value specified for CreateRelease") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | var r0 []byte | |||
| 145 | + | var r1 error | |||
| 146 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) ([]byte, error)); ok { | |||
| 147 | + | return returnFunc(ctx, p, owner, repo, body) | |||
| 148 | + | } | |||
| 149 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) []byte); ok { | |||
| 150 | + | r0 = returnFunc(ctx, p, owner, repo, body) | |||
| 151 | + | } else { | |||
| 152 | + | if ret.Get(0) != nil { | |||
| 153 | + | r0 = ret.Get(0).([]byte) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, []byte) error); ok { | |||
| 157 | + | r1 = returnFunc(ctx, p, owner, repo, body) | |||
| 158 | + | } else { | |||
| 159 | + | r1 = ret.Error(1) | |||
| 160 | + | } | |||
| 161 | + | return r0, r1 | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // GitHubReleaseAPI_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease' | |||
| 165 | + | type GitHubReleaseAPI_CreateRelease_Call struct { | |||
| 166 | + | *mock.Call | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // CreateRelease is a helper method to define mock.On call | |||
| 170 | + | // - ctx context.Context | |||
| 171 | + | // - p domain.Principal | |||
| 172 | + | // - owner string | |||
| 173 | + | // - repo string | |||
| 174 | + | // - body []byte | |||
| 175 | + | func (_e *GitHubReleaseAPI_Expecter) CreateRelease(ctx any, p any, owner any, repo any, body any) *GitHubReleaseAPI_CreateRelease_Call { | |||
| 176 | + | return &GitHubReleaseAPI_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, p, owner, repo, body)} | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte)) *GitHubReleaseAPI_CreateRelease_Call { | |||
| 180 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 181 | + | var arg0 context.Context | |||
| 182 | + | if args[0] != nil { | |||
| 183 | + | arg0 = args[0].(context.Context) | |||
| 184 | + | } | |||
| 185 | + | var arg1 domain.Principal | |||
| 186 | + | if args[1] != nil { | |||
| 187 | + | arg1 = args[1].(domain.Principal) | |||
| 188 | + | } | |||
| 189 | + | var arg2 string | |||
| 190 | + | if args[2] != nil { | |||
| 191 | + | arg2 = args[2].(string) | |||
| 192 | + | } | |||
| 193 | + | var arg3 string | |||
| 194 | + | if args[3] != nil { | |||
| 195 | + | arg3 = args[3].(string) | |||
| 196 | + | } | |||
| 197 | + | var arg4 []byte | |||
| 198 | + | if args[4] != nil { | |||
| 199 | + | arg4 = args[4].([]byte) | |||
| 200 | + | } | |||
| 201 | + | run( | |||
| 202 | + | arg0, | |||
| 203 | + | arg1, | |||
| 204 | + | arg2, | |||
| 205 | + | arg3, | |||
| 206 | + | arg4, | |||
| 207 | + | ) | |||
| 208 | + | }) | |||
| 209 | + | return _c | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_CreateRelease_Call { | |||
| 213 | + | _c.Call.Return(bytes, err) | |||
| 214 | + | return _c | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error)) *GitHubReleaseAPI_CreateRelease_Call { | |||
| 218 | + | _c.Call.Return(run) | |||
| 219 | + | return _c | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // DeleteAsset provides a mock function for the type GitHubReleaseAPI | |||
| 223 | + | func (_mock *GitHubReleaseAPI) DeleteAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error { | |||
| 224 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |||
| 225 | + | ||||
| 226 | + | if len(ret) == 0 { | |||
| 227 | + | panic("no return value specified for DeleteAsset") | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | var r0 error | |||
| 231 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |||
| 232 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |||
| 233 | + | } else { | |||
| 234 | + | r0 = ret.Error(0) | |||
| 235 | + | } | |||
| 236 | + | return r0 | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // GitHubReleaseAPI_DeleteAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAsset' | |||
| 240 | + | type GitHubReleaseAPI_DeleteAsset_Call struct { | |||
| 241 | + | *mock.Call | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // DeleteAsset is a helper method to define mock.On call | |||
| 245 | + | // - ctx context.Context | |||
| 246 | + | // - p domain.Principal | |||
| 247 | + | // - owner string | |||
| 248 | + | // - repo string | |||
| 249 | + | // - assetID int64 | |||
| 250 | + | func (_e *GitHubReleaseAPI_Expecter) DeleteAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_DeleteAsset_Call { | |||
| 251 | + | return &GitHubReleaseAPI_DeleteAsset_Call{Call: _e.mock.On("DeleteAsset", ctx, p, owner, repo, assetID)} | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_DeleteAsset_Call { | |||
| 255 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 256 | + | var arg0 context.Context | |||
| 257 | + | if args[0] != nil { | |||
| 258 | + | arg0 = args[0].(context.Context) | |||
| 259 | + | } | |||
| 260 | + | var arg1 domain.Principal | |||
| 261 | + | if args[1] != nil { | |||
| 262 | + | arg1 = args[1].(domain.Principal) | |||
| 263 | + | } | |||
| 264 | + | var arg2 string | |||
| 265 | + | if args[2] != nil { | |||
| 266 | + | arg2 = args[2].(string) | |||
| 267 | + | } | |||
| 268 | + | var arg3 string | |||
| 269 | + | if args[3] != nil { | |||
| 270 | + | arg3 = args[3].(string) | |||
| 271 | + | } | |||
| 272 | + | var arg4 int64 | |||
| 273 | + | if args[4] != nil { | |||
| 274 | + | arg4 = args[4].(int64) | |||
| 275 | + | } | |||
| 276 | + | run( | |||
| 277 | + | arg0, | |||
| 278 | + | arg1, | |||
| 279 | + | arg2, | |||
| 280 | + | arg3, | |||
| 281 | + | arg4, | |||
| 282 | + | ) | |||
| 283 | + | }) | |||
| 284 | + | return _c | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) Return(err error) *GitHubReleaseAPI_DeleteAsset_Call { | |||
| 288 | + | _c.Call.Return(err) | |||
| 289 | + | return _c | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error) *GitHubReleaseAPI_DeleteAsset_Call { | |||
| 293 | + | _c.Call.Return(run) | |||
| 294 | + | return _c | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // DeleteRelease provides a mock function for the type GitHubReleaseAPI | |||
| 298 | + | func (_mock *GitHubReleaseAPI) DeleteRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error { | |||
| 299 | + | ret := _mock.Called(ctx, p, owner, repo, id) | |||
| 300 | + | ||||
| 301 | + | if len(ret) == 0 { | |||
| 302 | + | panic("no return value specified for DeleteRelease") | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | var r0 error | |||
| 306 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |||
| 307 | + | r0 = returnFunc(ctx, p, owner, repo, id) | |||
| 308 | + | } else { | |||
| 309 | + | r0 = ret.Error(0) | |||
| 310 | + | } | |||
| 311 | + | return r0 | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // GitHubReleaseAPI_DeleteRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRelease' | |||
| 315 | + | type GitHubReleaseAPI_DeleteRelease_Call struct { | |||
| 316 | + | *mock.Call | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // DeleteRelease is a helper method to define mock.On call | |||
| 320 | + | // - ctx context.Context | |||
| 321 | + | // - p domain.Principal | |||
| 322 | + | // - owner string | |||
| 323 | + | // - repo string | |||
| 324 | + | // - id int64 | |||
| 325 | + | func (_e *GitHubReleaseAPI_Expecter) DeleteRelease(ctx any, p any, owner any, repo any, id any) *GitHubReleaseAPI_DeleteRelease_Call { | |||
| 326 | + | return &GitHubReleaseAPI_DeleteRelease_Call{Call: _e.mock.On("DeleteRelease", ctx, p, owner, repo, id)} | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64)) *GitHubReleaseAPI_DeleteRelease_Call { | |||
| 330 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 331 | + | var arg0 context.Context | |||
| 332 | + | if args[0] != nil { | |||
| 333 | + | arg0 = args[0].(context.Context) | |||
| 334 | + | } | |||
| 335 | + | var arg1 domain.Principal | |||
| 336 | + | if args[1] != nil { | |||
| 337 | + | arg1 = args[1].(domain.Principal) | |||
| 338 | + | } | |||
| 339 | + | var arg2 string | |||
| 340 | + | if args[2] != nil { | |||
| 341 | + | arg2 = args[2].(string) | |||
| 342 | + | } | |||
| 343 | + | var arg3 string | |||
| 344 | + | if args[3] != nil { | |||
| 345 | + | arg3 = args[3].(string) | |||
| 346 | + | } | |||
| 347 | + | var arg4 int64 | |||
| 348 | + | if args[4] != nil { | |||
| 349 | + | arg4 = args[4].(int64) | |||
| 350 | + | } | |||
| 351 | + | run( | |||
| 352 | + | arg0, | |||
| 353 | + | arg1, | |||
| 354 | + | arg2, | |||
| 355 | + | arg3, | |||
| 356 | + | arg4, | |||
| 357 | + | ) | |||
| 358 | + | }) | |||
| 359 | + | return _c | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) Return(err error) *GitHubReleaseAPI_DeleteRelease_Call { | |||
| 363 | + | _c.Call.Return(err) | |||
| 364 | + | return _c | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error) *GitHubReleaseAPI_DeleteRelease_Call { | |||
| 368 | + | _c.Call.Return(run) | |||
| 369 | + | return _c | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // GetAsset provides a mock function for the type GitHubReleaseAPI | |||
| 373 | + | func (_mock *GitHubReleaseAPI) GetAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error) { | |||
| 374 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |||
| 375 | + | ||||
| 376 | + | if len(ret) == 0 { | |||
| 377 | + | panic("no return value specified for GetAsset") | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | var r0 []byte | |||
| 381 | + | var r1 error | |||
| 382 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) ([]byte, error)); ok { | |||
| 383 | + | return returnFunc(ctx, p, owner, repo, assetID) | |||
| 384 | + | } | |||
| 385 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) []byte); ok { | |||
| 386 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |||
| 387 | + | } else { | |||
| 388 | + | if ret.Get(0) != nil { | |||
| 389 | + | r0 = ret.Get(0).([]byte) | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |||
| 393 | + | r1 = returnFunc(ctx, p, owner, repo, assetID) | |||
| 394 | + | } else { | |||
| 395 | + | r1 = ret.Error(1) | |||
| 396 | + | } | |||
| 397 | + | return r0, r1 | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // GitHubReleaseAPI_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset' | |||
| 401 | + | type GitHubReleaseAPI_GetAsset_Call struct { | |||
| 402 | + | *mock.Call | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // GetAsset is a helper method to define mock.On call | |||
| 406 | + | // - ctx context.Context | |||
| 407 | + | // - p domain.Principal | |||
| 408 | + | // - owner string | |||
| 409 | + | // - repo string | |||
| 410 | + | // - assetID int64 | |||
| 411 | + | func (_e *GitHubReleaseAPI_Expecter) GetAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_GetAsset_Call { | |||
| 412 | + | return &GitHubReleaseAPI_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, p, owner, repo, assetID)} | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_GetAsset_Call { | |||
| 416 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 417 | + | var arg0 context.Context | |||
| 418 | + | if args[0] != nil { | |||
| 419 | + | arg0 = args[0].(context.Context) | |||
| 420 | + | } | |||
| 421 | + | var arg1 domain.Principal | |||
| 422 | + | if args[1] != nil { | |||
| 423 | + | arg1 = args[1].(domain.Principal) | |||
| 424 | + | } | |||
| 425 | + | var arg2 string | |||
| 426 | + | if args[2] != nil { | |||
| 427 | + | arg2 = args[2].(string) | |||
| 428 | + | } | |||
| 429 | + | var arg3 string | |||
| 430 | + | if args[3] != nil { | |||
| 431 | + | arg3 = args[3].(string) | |||
| 432 | + | } | |||
| 433 | + | var arg4 int64 | |||
| 434 | + | if args[4] != nil { | |||
| 435 | + | arg4 = args[4].(int64) | |||
| 436 | + | } | |||
| 437 | + | run( | |||
| 438 | + | arg0, | |||
| 439 | + | arg1, | |||
| 440 | + | arg2, | |||
| 441 | + | arg3, | |||
| 442 | + | arg4, | |||
| 443 | + | ) | |||
| 444 | + | }) | |||
| 445 | + | return _c | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetAsset_Call { | |||
| 449 | + | _c.Call.Return(bytes, err) | |||
| 450 | + | return _c | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error)) *GitHubReleaseAPI_GetAsset_Call { | |||
| 454 | + | _c.Call.Return(run) | |||
| 455 | + | return _c | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | // GetRelease provides a mock function for the type GitHubReleaseAPI | |||
| 459 | + | func (_mock *GitHubReleaseAPI) GetRelease(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error) { | |||
| 460 | + | ret := _mock.Called(ctx, p, owner, repo, ref) | |||
| 461 | + | ||||
| 462 | + | if len(ret) == 0 { | |||
| 463 | + | panic("no return value specified for GetRelease") | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | var r0 []byte | |||
| 467 | + | var r1 error | |||
| 468 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) ([]byte, error)); ok { | |||
| 469 | + | return returnFunc(ctx, p, owner, repo, ref) | |||
| 470 | + | } | |||
| 471 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) []byte); ok { | |||
| 472 | + | r0 = returnFunc(ctx, p, owner, repo, ref) | |||
| 473 | + | } else { | |||
| 474 | + | if ret.Get(0) != nil { | |||
| 475 | + | r0 = ret.Get(0).([]byte) | |||
| 476 | + | } | |||
| 477 | + | } | |||
| 478 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) error); ok { | |||
| 479 | + | r1 = returnFunc(ctx, p, owner, repo, ref) | |||
| 480 | + | } else { | |||
| 481 | + | r1 = ret.Error(1) | |||
| 482 | + | } | |||
| 483 | + | return r0, r1 | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | // GitHubReleaseAPI_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease' | |||
| 487 | + | type GitHubReleaseAPI_GetRelease_Call struct { | |||
| 488 | + | *mock.Call | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // GetRelease is a helper method to define mock.On call | |||
| 492 | + | // - ctx context.Context | |||
| 493 | + | // - p domain.Principal | |||
| 494 | + | // - owner string | |||
| 495 | + | // - repo string | |||
| 496 | + | // - ref ports.ReleaseRef | |||
| 497 | + | func (_e *GitHubReleaseAPI_Expecter) GetRelease(ctx any, p any, owner any, repo any, ref any) *GitHubReleaseAPI_GetRelease_Call { | |||
| 498 | + | return &GitHubReleaseAPI_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, p, owner, repo, ref)} | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef)) *GitHubReleaseAPI_GetRelease_Call { | |||
| 502 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 503 | + | var arg0 context.Context | |||
| 504 | + | if args[0] != nil { | |||
| 505 | + | arg0 = args[0].(context.Context) | |||
| 506 | + | } | |||
| 507 | + | var arg1 domain.Principal | |||
| 508 | + | if args[1] != nil { | |||
| 509 | + | arg1 = args[1].(domain.Principal) | |||
| 510 | + | } | |||
| 511 | + | var arg2 string | |||
| 512 | + | if args[2] != nil { | |||
| 513 | + | arg2 = args[2].(string) | |||
| 514 | + | } | |||
| 515 | + | var arg3 string | |||
| 516 | + | if args[3] != nil { | |||
| 517 | + | arg3 = args[3].(string) | |||
| 518 | + | } | |||
| 519 | + | var arg4 ports.ReleaseRef | |||
| 520 | + | if args[4] != nil { | |||
| 521 | + | arg4 = args[4].(ports.ReleaseRef) | |||
| 522 | + | } | |||
| 523 | + | run( | |||
| 524 | + | arg0, | |||
| 525 | + | arg1, | |||
| 526 | + | arg2, | |||
| 527 | + | arg3, | |||
| 528 | + | arg4, | |||
| 529 | + | ) | |||
| 530 | + | }) | |||
| 531 | + | return _c | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetRelease_Call { | |||
| 535 | + | _c.Call.Return(bytes, err) | |||
| 536 | + | return _c | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error)) *GitHubReleaseAPI_GetRelease_Call { | |||
| 540 | + | _c.Call.Return(run) | |||
| 541 | + | return _c | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | // ListAssets provides a mock function for the type GitHubReleaseAPI | |||
| 545 | + | func (_mock *GitHubReleaseAPI) ListAssets(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error) { | |||
| 546 | + | ret := _mock.Called(ctx, p, owner, repo, id, page, perPage) | |||
| 547 | + | ||||
| 548 | + | if len(ret) == 0 { | |||
| 549 | + | panic("no return value specified for ListAssets") | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | var r0 []byte | |||
| 553 | + | var r1 string | |||
| 554 | + | var r2 error | |||
| 555 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) ([]byte, string, error)); ok { | |||
| 556 | + | return returnFunc(ctx, p, owner, repo, id, page, perPage) | |||
| 557 | + | } | |||
| 558 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) []byte); ok { | |||
| 559 | + | r0 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |||
| 560 | + | } else { | |||
| 561 | + | if ret.Get(0) != nil { | |||
| 562 | + | r0 = ret.Get(0).([]byte) | |||
| 563 | + | } | |||
| 564 | + | } | |||
| 565 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, int, int) string); ok { | |||
| 566 | + | r1 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |||
| 567 | + | } else { | |||
| 568 | + | r1 = ret.Get(1).(string) | |||
| 569 | + | } | |||
| 570 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int64, int, int) error); ok { | |||
| 571 | + | r2 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |||
| 572 | + | } else { | |||
| 573 | + | r2 = ret.Error(2) | |||
| 574 | + | } | |||
| 575 | + | return r0, r1, r2 | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | // GitHubReleaseAPI_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets' | |||
| 579 | + | type GitHubReleaseAPI_ListAssets_Call struct { | |||
| 580 | + | *mock.Call | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | // ListAssets is a helper method to define mock.On call | |||
| 584 | + | // - ctx context.Context | |||
| 585 | + | // - p domain.Principal | |||
| 586 | + | // - owner string | |||
| 587 | + | // - repo string | |||
| 588 | + | // - id int64 | |||
| 589 | + | // - page int | |||
| 590 | + | // - perPage int | |||
| 591 | + | func (_e *GitHubReleaseAPI_Expecter) ListAssets(ctx any, p any, owner any, repo any, id any, page any, perPage any) *GitHubReleaseAPI_ListAssets_Call { | |||
| 592 | + | return &GitHubReleaseAPI_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, p, owner, repo, id, page, perPage)} | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int)) *GitHubReleaseAPI_ListAssets_Call { | |||
| 596 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 597 | + | var arg0 context.Context | |||
| 598 | + | if args[0] != nil { | |||
| 599 | + | arg0 = args[0].(context.Context) | |||
| 600 | + | } | |||
| 601 | + | var arg1 domain.Principal | |||
| 602 | + | if args[1] != nil { | |||
| 603 | + | arg1 = args[1].(domain.Principal) | |||
| 604 | + | } | |||
| 605 | + | var arg2 string | |||
| 606 | + | if args[2] != nil { | |||
| 607 | + | arg2 = args[2].(string) | |||
| 608 | + | } | |||
| 609 | + | var arg3 string | |||
| 610 | + | if args[3] != nil { | |||
| 611 | + | arg3 = args[3].(string) | |||
| 612 | + | } | |||
| 613 | + | var arg4 int64 | |||
| 614 | + | if args[4] != nil { | |||
| 615 | + | arg4 = args[4].(int64) | |||
| 616 | + | } | |||
| 617 | + | var arg5 int | |||
| 618 | + | if args[5] != nil { | |||
| 619 | + | arg5 = args[5].(int) | |||
| 620 | + | } | |||
| 621 | + | var arg6 int | |||
| 622 | + | if args[6] != nil { | |||
| 623 | + | arg6 = args[6].(int) | |||
| 624 | + | } | |||
| 625 | + | run( | |||
| 626 | + | arg0, | |||
| 627 | + | arg1, | |||
| 628 | + | arg2, | |||
| 629 | + | arg3, | |||
| 630 | + | arg4, | |||
| 631 | + | arg5, | |||
| 632 | + | arg6, | |||
| 633 | + | ) | |||
| 634 | + | }) | |||
| 635 | + | return _c | |||
| 636 | + | } | |||
| 637 | + | ||||
| 638 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListAssets_Call { | |||
| 639 | + | _c.Call.Return(body, link, err) | |||
| 640 | + | return _c | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListAssets_Call { | |||
| 644 | + | _c.Call.Return(run) | |||
| 645 | + | return _c | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | // ListReleases provides a mock function for the type GitHubReleaseAPI | |||
| 649 | + | func (_mock *GitHubReleaseAPI) ListReleases(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error) { | |||
| 650 | + | ret := _mock.Called(ctx, p, owner, repo, page, perPage) | |||
| 651 | + | ||||
| 652 | + | if len(ret) == 0 { | |||
| 653 | + | panic("no return value specified for ListReleases") | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | var r0 []byte | |||
| 657 | + | var r1 string | |||
| 658 | + | var r2 error | |||
| 659 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) ([]byte, string, error)); ok { | |||
| 660 | + | return returnFunc(ctx, p, owner, repo, page, perPage) | |||
| 661 | + | } | |||
| 662 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) []byte); ok { | |||
| 663 | + | r0 = returnFunc(ctx, p, owner, repo, page, perPage) | |||
| 664 | + | } else { | |||
| 665 | + | if ret.Get(0) != nil { | |||
| 666 | + | r0 = ret.Get(0).([]byte) | |||
| 667 | + | } | |||
| 668 | + | } | |||
| 669 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int, int) string); ok { | |||
| 670 | + | r1 = returnFunc(ctx, p, owner, repo, page, perPage) | |||
| 671 | + | } else { | |||
| 672 | + | r1 = ret.Get(1).(string) | |||
| 673 | + | } | |||
| 674 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int, int) error); ok { | |||
| 675 | + | r2 = returnFunc(ctx, p, owner, repo, page, perPage) | |||
| 676 | + | } else { | |||
| 677 | + | r2 = ret.Error(2) | |||
| 678 | + | } | |||
| 679 | + | return r0, r1, r2 | |||
| 680 | + | } | |||
| 681 | + | ||||
| 682 | + | // GitHubReleaseAPI_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases' | |||
| 683 | + | type GitHubReleaseAPI_ListReleases_Call struct { | |||
| 684 | + | *mock.Call | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | // ListReleases is a helper method to define mock.On call | |||
| 688 | + | // - ctx context.Context | |||
| 689 | + | // - p domain.Principal | |||
| 690 | + | // - owner string | |||
| 691 | + | // - repo string | |||
| 692 | + | // - page int | |||
| 693 | + | // - perPage int | |||
| 694 | + | func (_e *GitHubReleaseAPI_Expecter) ListReleases(ctx any, p any, owner any, repo any, page any, perPage any) *GitHubReleaseAPI_ListReleases_Call { | |||
| 695 | + | return &GitHubReleaseAPI_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, p, owner, repo, page, perPage)} | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int)) *GitHubReleaseAPI_ListReleases_Call { | |||
| 699 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 700 | + | var arg0 context.Context | |||
| 701 | + | if args[0] != nil { | |||
| 702 | + | arg0 = args[0].(context.Context) | |||
| 703 | + | } | |||
| 704 | + | var arg1 domain.Principal | |||
| 705 | + | if args[1] != nil { | |||
| 706 | + | arg1 = args[1].(domain.Principal) | |||
| 707 | + | } | |||
| 708 | + | var arg2 string | |||
| 709 | + | if args[2] != nil { | |||
| 710 | + | arg2 = args[2].(string) | |||
| 711 | + | } | |||
| 712 | + | var arg3 string | |||
| 713 | + | if args[3] != nil { | |||
| 714 | + | arg3 = args[3].(string) | |||
| 715 | + | } | |||
| 716 | + | var arg4 int | |||
| 717 | + | if args[4] != nil { | |||
| 718 | + | arg4 = args[4].(int) | |||
| 719 | + | } | |||
| 720 | + | var arg5 int | |||
| 721 | + | if args[5] != nil { | |||
| 722 | + | arg5 = args[5].(int) | |||
| 723 | + | } | |||
| 724 | + | run( | |||
| 725 | + | arg0, | |||
| 726 | + | arg1, | |||
| 727 | + | arg2, | |||
| 728 | + | arg3, | |||
| 729 | + | arg4, | |||
| 730 | + | arg5, | |||
| 731 | + | ) | |||
| 732 | + | }) | |||
| 733 | + | return _c | |||
| 734 | + | } | |||
| 735 | + | ||||
| 736 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListReleases_Call { | |||
| 737 | + | _c.Call.Return(body, link, err) | |||
| 738 | + | return _c | |||
| 739 | + | } | |||
| 740 | + | ||||
| 741 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListReleases_Call { | |||
| 742 | + | _c.Call.Return(run) | |||
| 743 | + | return _c | |||
| 744 | + | } | |||
| 745 | + | ||||
| 746 | + | // UpdateAsset provides a mock function for the type GitHubReleaseAPI | |||
| 747 | + | func (_mock *GitHubReleaseAPI) UpdateAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error) { | |||
| 748 | + | ret := _mock.Called(ctx, p, owner, repo, assetID, body) | |||
| 749 | + | ||||
| 750 | + | if len(ret) == 0 { | |||
| 751 | + | panic("no return value specified for UpdateAsset") | |||
| 752 | + | } | |||
| 753 | + | ||||
| 754 | + | var r0 []byte | |||
| 755 | + | var r1 error | |||
| 756 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok { | |||
| 757 | + | return returnFunc(ctx, p, owner, repo, assetID, body) | |||
| 758 | + | } | |||
| 759 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok { | |||
| 760 | + | r0 = returnFunc(ctx, p, owner, repo, assetID, body) | |||
| 761 | + | } else { | |||
| 762 | + | if ret.Get(0) != nil { | |||
| 763 | + | r0 = ret.Get(0).([]byte) | |||
| 764 | + | } | |||
| 765 | + | } | |||
| 766 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok { | |||
| 767 | + | r1 = returnFunc(ctx, p, owner, repo, assetID, body) | |||
| 768 | + | } else { | |||
| 769 | + | r1 = ret.Error(1) | |||
| 770 | + | } | |||
| 771 | + | return r0, r1 | |||
| 772 | + | } | |||
| 773 | + | ||||
| 774 | + | // GitHubReleaseAPI_UpdateAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAsset' | |||
| 775 | + | type GitHubReleaseAPI_UpdateAsset_Call struct { | |||
| 776 | + | *mock.Call | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | // UpdateAsset is a helper method to define mock.On call | |||
| 780 | + | // - ctx context.Context | |||
| 781 | + | // - p domain.Principal | |||
| 782 | + | // - owner string | |||
| 783 | + | // - repo string | |||
| 784 | + | // - assetID int64 | |||
| 785 | + | // - body []byte | |||
| 786 | + | func (_e *GitHubReleaseAPI_Expecter) UpdateAsset(ctx any, p any, owner any, repo any, assetID any, body any) *GitHubReleaseAPI_UpdateAsset_Call { | |||
| 787 | + | return &GitHubReleaseAPI_UpdateAsset_Call{Call: _e.mock.On("UpdateAsset", ctx, p, owner, repo, assetID, body)} | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte)) *GitHubReleaseAPI_UpdateAsset_Call { | |||
| 791 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 792 | + | var arg0 context.Context | |||
| 793 | + | if args[0] != nil { | |||
| 794 | + | arg0 = args[0].(context.Context) | |||
| 795 | + | } | |||
| 796 | + | var arg1 domain.Principal | |||
| 797 | + | if args[1] != nil { | |||
| 798 | + | arg1 = args[1].(domain.Principal) | |||
| 799 | + | } | |||
| 800 | + | var arg2 string | |||
| 801 | + | if args[2] != nil { | |||
| 802 | + | arg2 = args[2].(string) | |||
| 803 | + | } | |||
| 804 | + | var arg3 string | |||
| 805 | + | if args[3] != nil { | |||
| 806 | + | arg3 = args[3].(string) | |||
| 807 | + | } | |||
| 808 | + | var arg4 int64 | |||
| 809 | + | if args[4] != nil { | |||
| 810 | + | arg4 = args[4].(int64) | |||
| 811 | + | } | |||
| 812 | + | var arg5 []byte | |||
| 813 | + | if args[5] != nil { | |||
| 814 | + | arg5 = args[5].([]byte) | |||
| 815 | + | } | |||
| 816 | + | run( | |||
| 817 | + | arg0, | |||
| 818 | + | arg1, | |||
| 819 | + | arg2, | |||
| 820 | + | arg3, | |||
| 821 | + | arg4, | |||
| 822 | + | arg5, | |||
| 823 | + | ) | |||
| 824 | + | }) | |||
| 825 | + | return _c | |||
| 826 | + | } | |||
| 827 | + | ||||
| 828 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateAsset_Call { | |||
| 829 | + | _c.Call.Return(bytes, err) | |||
| 830 | + | return _c | |||
| 831 | + | } | |||
| 832 | + | ||||
| 833 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateAsset_Call { | |||
| 834 | + | _c.Call.Return(run) | |||
| 835 | + | return _c | |||
| 836 | + | } | |||
| 837 | + | ||||
| 838 | + | // UpdateRelease provides a mock function for the type GitHubReleaseAPI | |||
| 839 | + | func (_mock *GitHubReleaseAPI) UpdateRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error) { | |||
| 840 | + | ret := _mock.Called(ctx, p, owner, repo, id, body) | |||
| 841 | + | ||||
| 842 | + | if len(ret) == 0 { | |||
| 843 | + | panic("no return value specified for UpdateRelease") | |||
| 844 | + | } | |||
| 845 | + | ||||
| 846 | + | var r0 []byte | |||
| 847 | + | var r1 error | |||
| 848 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok { | |||
| 849 | + | return returnFunc(ctx, p, owner, repo, id, body) | |||
| 850 | + | } | |||
| 851 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok { | |||
| 852 | + | r0 = returnFunc(ctx, p, owner, repo, id, body) | |||
| 853 | + | } else { | |||
| 854 | + | if ret.Get(0) != nil { | |||
| 855 | + | r0 = ret.Get(0).([]byte) | |||
| 856 | + | } | |||
| 857 | + | } | |||
| 858 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok { | |||
| 859 | + | r1 = returnFunc(ctx, p, owner, repo, id, body) | |||
| 860 | + | } else { | |||
| 861 | + | r1 = ret.Error(1) | |||
| 862 | + | } | |||
| 863 | + | return r0, r1 | |||
| 864 | + | } | |||
| 865 | + | ||||
| 866 | + | // GitHubReleaseAPI_UpdateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRelease' | |||
| 867 | + | type GitHubReleaseAPI_UpdateRelease_Call struct { | |||
| 868 | + | *mock.Call | |||
| 869 | + | } | |||
| 870 | + | ||||
| 871 | + | // UpdateRelease is a helper method to define mock.On call | |||
| 872 | + | // - ctx context.Context | |||
| 873 | + | // - p domain.Principal | |||
| 874 | + | // - owner string | |||
| 875 | + | // - repo string | |||
| 876 | + | // - id int64 | |||
| 877 | + | // - body []byte | |||
| 878 | + | func (_e *GitHubReleaseAPI_Expecter) UpdateRelease(ctx any, p any, owner any, repo any, id any, body any) *GitHubReleaseAPI_UpdateRelease_Call { | |||
| 879 | + | return &GitHubReleaseAPI_UpdateRelease_Call{Call: _e.mock.On("UpdateRelease", ctx, p, owner, repo, id, body)} | |||
| 880 | + | } | |||
| 881 | + | ||||
| 882 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte)) *GitHubReleaseAPI_UpdateRelease_Call { | |||
| 883 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 884 | + | var arg0 context.Context | |||
| 885 | + | if args[0] != nil { | |||
| 886 | + | arg0 = args[0].(context.Context) | |||
| 887 | + | } | |||
| 888 | + | var arg1 domain.Principal | |||
| 889 | + | if args[1] != nil { | |||
| 890 | + | arg1 = args[1].(domain.Principal) | |||
| 891 | + | } | |||
| 892 | + | var arg2 string | |||
| 893 | + | if args[2] != nil { | |||
| 894 | + | arg2 = args[2].(string) | |||
| 895 | + | } | |||
| 896 | + | var arg3 string | |||
| 897 | + | if args[3] != nil { | |||
| 898 | + | arg3 = args[3].(string) | |||
| 899 | + | } | |||
| 900 | + | var arg4 int64 | |||
| 901 | + | if args[4] != nil { | |||
| 902 | + | arg4 = args[4].(int64) | |||
| 903 | + | } | |||
| 904 | + | var arg5 []byte | |||
| 905 | + | if args[5] != nil { | |||
| 906 | + | arg5 = args[5].([]byte) | |||
| 907 | + | } | |||
| 908 | + | run( | |||
| 909 | + | arg0, | |||
| 910 | + | arg1, | |||
| 911 | + | arg2, | |||
| 912 | + | arg3, | |||
| 913 | + | arg4, | |||
| 914 | + | arg5, | |||
| 915 | + | ) | |||
| 916 | + | }) | |||
| 917 | + | return _c | |||
| 918 | + | } | |||
| 919 | + | ||||
| 920 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateRelease_Call { | |||
| 921 | + | _c.Call.Return(bytes, err) | |||
| 922 | + | return _c | |||
| 923 | + | } | |||
| 924 | + | ||||
| 925 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateRelease_Call { | |||
| 926 | + | _c.Call.Return(run) | |||
| 927 | + | return _c | |||
| 928 | + | } | |||
| 929 | + | ||||
| 930 | + | // UploadAsset provides a mock function for the type GitHubReleaseAPI | |||
| 931 | + | func (_mock *GitHubReleaseAPI) UploadAsset(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error) { | |||
| 932 | + | ret := _mock.Called(ctx, p, owner, repo, id, name, label, contentType, size, r) | |||
| 933 | + | ||||
| 934 | + | if len(ret) == 0 { | |||
| 935 | + | panic("no return value specified for UploadAsset") | |||
| 936 | + | } | |||
| 937 | + | ||||
| 938 | + | var r0 []byte | |||
| 939 | + | var r1 error | |||
| 940 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) ([]byte, error)); ok { | |||
| 941 | + | return returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |||
| 942 | + | } | |||
| 943 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) []byte); ok { | |||
| 944 | + | r0 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |||
| 945 | + | } else { | |||
| 946 | + | if ret.Get(0) != nil { | |||
| 947 | + | r0 = ret.Get(0).([]byte) | |||
| 948 | + | } | |||
| 949 | + | } | |||
| 950 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) error); ok { | |||
| 951 | + | r1 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |||
| 952 | + | } else { | |||
| 953 | + | r1 = ret.Error(1) | |||
| 954 | + | } | |||
| 955 | + | return r0, r1 | |||
| 956 | + | } | |||
| 957 | + | ||||
| 958 | + | // GitHubReleaseAPI_UploadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadAsset' | |||
| 959 | + | type GitHubReleaseAPI_UploadAsset_Call struct { | |||
| 960 | + | *mock.Call | |||
| 961 | + | } | |||
| 962 | + | ||||
| 963 | + | // UploadAsset is a helper method to define mock.On call | |||
| 964 | + | // - ctx context.Context | |||
| 965 | + | // - p domain.Principal | |||
| 966 | + | // - owner string | |||
| 967 | + | // - repo string | |||
| 968 | + | // - id int64 | |||
| 969 | + | // - name string | |||
| 970 | + | // - label string | |||
| 971 | + | // - contentType string | |||
| 972 | + | // - size int64 | |||
| 973 | + | // - r io.Reader | |||
| 974 | + | func (_e *GitHubReleaseAPI_Expecter) UploadAsset(ctx any, p any, owner any, repo any, id any, name any, label any, contentType any, size any, r any) *GitHubReleaseAPI_UploadAsset_Call { | |||
| 975 | + | return &GitHubReleaseAPI_UploadAsset_Call{Call: _e.mock.On("UploadAsset", ctx, p, owner, repo, id, name, label, contentType, size, r)} | |||
| 976 | + | } | |||
| 977 | + | ||||
| 978 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader)) *GitHubReleaseAPI_UploadAsset_Call { | |||
| 979 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 980 | + | var arg0 context.Context | |||
| 981 | + | if args[0] != nil { | |||
| 982 | + | arg0 = args[0].(context.Context) | |||
| 983 | + | } | |||
| 984 | + | var arg1 domain.Principal | |||
| 985 | + | if args[1] != nil { | |||
| 986 | + | arg1 = args[1].(domain.Principal) | |||
| 987 | + | } | |||
| 988 | + | var arg2 string | |||
| 989 | + | if args[2] != nil { | |||
| 990 | + | arg2 = args[2].(string) | |||
| 991 | + | } | |||
| 992 | + | var arg3 string | |||
| 993 | + | if args[3] != nil { | |||
| 994 | + | arg3 = args[3].(string) | |||
| 995 | + | } | |||
| 996 | + | var arg4 int64 | |||
| 997 | + | if args[4] != nil { | |||
| 998 | + | arg4 = args[4].(int64) | |||
| 999 | + | } | |||
| 1000 | + | var arg5 string | |||
| 1001 | + | if args[5] != nil { | |||
| 1002 | + | arg5 = args[5].(string) | |||
| 1003 | + | } | |||
| 1004 | + | var arg6 string | |||
| 1005 | + | if args[6] != nil { | |||
| 1006 | + | arg6 = args[6].(string) | |||
| 1007 | + | } | |||
| 1008 | + | var arg7 string | |||
| 1009 | + | if args[7] != nil { | |||
| 1010 | + | arg7 = args[7].(string) | |||
| 1011 | + | } | |||
| 1012 | + | var arg8 int64 | |||
| 1013 | + | if args[8] != nil { | |||
| 1014 | + | arg8 = args[8].(int64) | |||
| 1015 | + | } | |||
| 1016 | + | var arg9 io.Reader | |||
| 1017 | + | if args[9] != nil { | |||
| 1018 | + | arg9 = args[9].(io.Reader) | |||
| 1019 | + | } | |||
| 1020 | + | run( | |||
| 1021 | + | arg0, | |||
| 1022 | + | arg1, | |||
| 1023 | + | arg2, | |||
| 1024 | + | arg3, | |||
| 1025 | + | arg4, | |||
| 1026 | + | arg5, | |||
| 1027 | + | arg6, | |||
| 1028 | + | arg7, | |||
| 1029 | + | arg8, | |||
| 1030 | + | arg9, | |||
| 1031 | + | ) | |||
| 1032 | + | }) | |||
| 1033 | + | return _c | |||
| 1034 | + | } | |||
| 1035 | + | ||||
| 1036 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UploadAsset_Call { | |||
| 1037 | + | _c.Call.Return(bytes, err) | |||
| 1038 | + | return _c | |||
| 1039 | + | } | |||
| 1040 | + | ||||
| 1041 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error)) *GitHubReleaseAPI_UploadAsset_Call { | |||
| 1042 | + | _c.Call.Return(run) | |||
| 1043 | + | return _c | |||
| 1044 | + | } | |||
M internal/ports/services.go +17 -0
| @@ -390,3 +390,20 @@ type EnvironmentService interface { | |||
| 390 | 390 | ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error) | |
| 391 | 391 | DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error | |
| 392 | 392 | } | |
| 393 | + | ||
| 394 | + | // GitHubReleaseAPI is the Releases part of the REST subset. owner/repo follow | |
| 395 | + | // GitHubAPIService; bodies are GitHub JSON. *usecase.ReleasesGitHub implements it. | |
| 396 | + | type GitHubReleaseAPI interface { | |
| 397 | + | CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) | |
| 398 | + | GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ReleaseRef) ([]byte, error) | |
| 399 | + | ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) (body []byte, link string, err error) | |
| 400 | + | UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) | |
| 401 | + | DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error | |
| 402 | + | ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) (body []byte, link string, err error) | |
| 403 | + | UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, r io.Reader) ([]byte, error) | |
| 404 | + | GetAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) ([]byte, error) | |
| 405 | + | UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64, body []byte) ([]byte, error) | |
| 406 | + | DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) error | |
| 407 | + | // AssetByID resolves metadata for a download through ReleaseService. | |
| 408 | + | AssetByID(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) (domain.ReleaseAsset, error) | |
| 409 | + | } | |
| @@ -390,3 +390,20 @@ type EnvironmentService interface { | |||||
| 390 | ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error) | 390 | ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error) | ||
| 391 | DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error | 391 | DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error | ||
| 392 | } | 392 | } | ||
| 393 | + | ||||
| 394 | + | // GitHubReleaseAPI is the Releases part of the REST subset. owner/repo follow | |||
| 395 | + | // GitHubAPIService; bodies are GitHub JSON. *usecase.ReleasesGitHub implements it. | |||
| 396 | + | type GitHubReleaseAPI interface { | |||
| 397 | + | CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) | |||
| 398 | + | GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ReleaseRef) ([]byte, error) | |||
| 399 | + | ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) (body []byte, link string, err error) | |||
| 400 | + | UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) | |||
| 401 | + | DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error | |||
| 402 | + | ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) (body []byte, link string, err error) | |||
| 403 | + | UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, r io.Reader) ([]byte, error) | |||
| 404 | + | GetAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) ([]byte, error) | |||
| 405 | + | UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64, body []byte) ([]byte, error) | |||
| 406 | + | DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) error | |||
| 407 | + | // AssetByID resolves metadata for a download through ReleaseService. | |||
| 408 | + | AssetByID(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) (domain.ReleaseAsset, error) | |||
| 409 | + | } | |||
A internal/usecase/releases_github.go +387 -0
| @@ -0,0 +1,387 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/url" | |
| 11 | + | "slices" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // ReleasesGitHub builds the GitHub REST representations of releases. Authorization | |
| 20 | + | // and all mutations remain in Releases; it owns no metadata or contents. | |
| 21 | + | type ReleasesGitHub struct { | |
| 22 | + | rel *Releases | |
| 23 | + | owner, publicURL string | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | var _ ports.GitHubReleaseAPI = (*ReleasesGitHub)(nil) | |
| 27 | + | ||
| 28 | + | // NewReleasesGitHub wraps the shared release service. publicURL is the external | |
| 29 | + | // server URL (without the API prefix), used in upload and browser download URLs. | |
| 30 | + | func NewReleasesGitHub(rel *Releases, owner, publicURL string) *ReleasesGitHub { | |
| 31 | + | return &ReleasesGitHub{rel: rel, owner: owner, publicURL: strings.TrimRight(publicURL, "/")} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (g *ReleasesGitHub) repo(owner, name string) (domain.RepoName, error) { | |
| 35 | + | if !domain.OwnerMatches(g.owner, owner) { | |
| 36 | + | return domain.RepoName{}, domain.ErrNotFound | |
| 37 | + | } | |
| 38 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |
| 39 | + | if err != nil { | |
| 40 | + | return domain.RepoName{}, domain.ErrNotFound | |
| 41 | + | } | |
| 42 | + | return rn, nil | |
| 43 | + | } | |
| 44 | + | func (g *ReleasesGitHub) base(repo domain.RepoName) string { | |
| 45 | + | return g.publicURL + apiPath + "/repos/" + url.PathEscape(g.owner) + "/" + url.PathEscape(repo.String()) | |
| 46 | + | } | |
| 47 | + | func (g *ReleasesGitHub) user(label string) map[string]any { | |
| 48 | + | if label == "" || label == "root" || label == "owner" { | |
| 49 | + | label = g.owner | |
| 50 | + | } | |
| 51 | + | u := ghURLs{server: g.publicURL, api: g.publicURL + apiPath} | |
| 52 | + | return u.userObj(ghIdentity{login: label, id: stableID("user", label)}, false) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // escapeReleaseTag preserves tag separators: the web routes join the tag path. | |
| 56 | + | func escapeReleaseTag(tag string) string { | |
| 57 | + | parts := strings.Split(tag, "/") | |
| 58 | + | for i := range parts { | |
| 59 | + | parts[i] = url.PathEscape(parts[i]) | |
| 60 | + | } | |
| 61 | + | return strings.Join(parts, "/") | |
| 62 | + | } | |
| 63 | + | func (g *ReleasesGitHub) assetObj(a domain.ReleaseAsset, tag string) ordered { | |
| 64 | + | return ordered{ | |
| 65 | + | {"url", g.base(a.Repo) + "/releases/assets/" + strconv.FormatInt(a.ID, 10)}, | |
| 66 | + | {"id", a.ID}, {"node_id", ""}, {"name", a.Name}, {"label", a.Label}, | |
| 67 | + | {"uploader", g.user(a.Uploader)}, {"content_type", a.ContentType}, {"state", "uploaded"}, | |
| 68 | + | {"size", a.Size}, {"digest", a.Digest}, {"download_count", 0}, | |
| 69 | + | {"created_at", a.CreatedAt.UTC().Format(timeLayout)}, {"updated_at", a.UpdatedAt.UTC().Format(timeLayout)}, | |
| 70 | + | {"browser_download_url", g.publicURL + "/" + a.Repo.String() + "/releases/download/" + escapeReleaseTag(tag) + "/" + url.PathEscape(a.Name)}, | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | func (g *ReleasesGitHub) releaseObj(ctx context.Context, p domain.Principal, r domain.Release) (ordered, error) { | |
| 74 | + | assets, err := g.rel.ListAssets(ctx, p, r.Repo, r.ID) | |
| 75 | + | if err != nil { | |
| 76 | + | return nil, err | |
| 77 | + | } | |
| 78 | + | objects := make([]ordered, 0, len(assets)) | |
| 79 | + | for _, a := range assets { | |
| 80 | + | objects = append(objects, g.assetObj(a, r.Tag)) | |
| 81 | + | } | |
| 82 | + | base := g.base(r.Repo) + "/releases/" + strconv.FormatInt(r.ID, 10) | |
| 83 | + | var published any | |
| 84 | + | if !r.Draft && !r.PublishedAt.IsZero() { | |
| 85 | + | published = r.PublishedAt.UTC().Format(timeLayout) | |
| 86 | + | } | |
| 87 | + | return ordered{ | |
| 88 | + | {"url", base}, {"assets_url", base + "/assets"}, | |
| 89 | + | {"upload_url", g.publicURL + apiPath + "/uploads/repos/" + url.PathEscape(g.owner) + "/" + r.Repo.String() + "/releases/" + strconv.FormatInt(r.ID, 10) + "/assets{?name,label}"}, | |
| 90 | + | {"html_url", g.publicURL + "/" + r.Repo.String() + "/releases/tag/" + escapeReleaseTag(r.Tag)}, | |
| 91 | + | {"id", r.ID}, {"author", g.user(r.Author)}, {"node_id", ""}, {"tag_name", r.Tag}, {"target_commitish", r.TargetCommitish}, | |
| 92 | + | {"name", r.Title}, {"draft", r.Draft}, {"prerelease", r.Prerelease}, {"created_at", r.CreatedAt.UTC().Format(timeLayout)}, | |
| 93 | + | {"published_at", published}, {"assets", objects}, {"tarball_url", g.base(r.Repo) + "/tarball/" + escapeReleaseTag(r.Tag)}, | |
| 94 | + | {"zipball_url", nil}, {"body", r.Notes}, | |
| 95 | + | }, nil | |
| 96 | + | } | |
| 97 | + | func (g *ReleasesGitHub) marshalRelease(ctx context.Context, p domain.Principal, r domain.Release) ([]byte, error) { | |
| 98 | + | o, err := g.releaseObj(ctx, p, r) | |
| 99 | + | if err != nil { | |
| 100 | + | return nil, err | |
| 101 | + | } | |
| 102 | + | return json.Marshal(o) | |
| 103 | + | } | |
| 104 | + | func releaseFields(body []byte) (map[string]json.RawMessage, error) { | |
| 105 | + | var fields map[string]json.RawMessage | |
| 106 | + | if err := json.Unmarshal(body, &fields); err != nil || fields == nil { | |
| 107 | + | return nil, fmt.Errorf("%w: JSON object required", domain.ErrInvalid) | |
| 108 | + | } | |
| 109 | + | return fields, nil | |
| 110 | + | } | |
| 111 | + | func releaseField[T any](fields map[string]json.RawMessage, key string) (*T, error) { | |
| 112 | + | b, ok := fields[key] | |
| 113 | + | if !ok { | |
| 114 | + | return nil, nil | |
| 115 | + | } | |
| 116 | + | var v T | |
| 117 | + | if string(b) == "null" || json.Unmarshal(b, &v) != nil { | |
| 118 | + | return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, key) | |
| 119 | + | } | |
| 120 | + | return &v, nil | |
| 121 | + | } | |
| 122 | + | func releaseParams(body []byte) (ports.UpdateReleaseParams, error) { | |
| 123 | + | var p ports.UpdateReleaseParams | |
| 124 | + | f, err := releaseFields(body) | |
| 125 | + | if err != nil { | |
| 126 | + | return p, err | |
| 127 | + | } | |
| 128 | + | for key, dst := range map[string]**string{"tag_name": &p.Tag, "target_commitish": &p.TargetCommitish, "name": &p.Title, "body": &p.Notes} { | |
| 129 | + | *dst, err = releaseField[string](f, key) | |
| 130 | + | if err != nil { | |
| 131 | + | return p, err | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | for key, dst := range map[string]**bool{"draft": &p.Draft, "prerelease": &p.Prerelease} { | |
| 135 | + | *dst, err = releaseField[bool](f, key) | |
| 136 | + | if err != nil { | |
| 137 | + | return p, err | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | latest, err := releaseField[string](f, "make_latest") | |
| 141 | + | if err != nil { | |
| 142 | + | return p, err | |
| 143 | + | } | |
| 144 | + | if latest != nil { | |
| 145 | + | switch *latest { | |
| 146 | + | case "true": | |
| 147 | + | v := true | |
| 148 | + | p.MakeLatest = &v | |
| 149 | + | case "false": | |
| 150 | + | v := false | |
| 151 | + | p.MakeLatest = &v | |
| 152 | + | case "legacy": | |
| 153 | + | default: | |
| 154 | + | return p, fmt.Errorf("%w: make_latest", domain.ErrInvalid) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return p, nil | |
| 158 | + | } | |
| 159 | + | func value[T any](p *T) (v T) { | |
| 160 | + | if p != nil { | |
| 161 | + | return *p | |
| 162 | + | } | |
| 163 | + | return v | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // CreateRelease accepts GitHub's create body and returns its representation. | |
| 167 | + | func (g *ReleasesGitHub) CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) { | |
| 168 | + | rn, err := g.repo(owner, repo) | |
| 169 | + | if err != nil { | |
| 170 | + | return nil, err | |
| 171 | + | } | |
| 172 | + | params, err := releaseParams(body) | |
| 173 | + | if err != nil { | |
| 174 | + | return nil, err | |
| 175 | + | } | |
| 176 | + | if params.Tag == nil || *params.Tag == "" { | |
| 177 | + | return nil, fmt.Errorf("%w: tag_name", domain.ErrInvalid) | |
| 178 | + | } | |
| 179 | + | r, err := g.rel.CreateRelease(ctx, p, rn, ports.CreateReleaseParams{Tag: *params.Tag, TargetCommitish: value(params.TargetCommitish), Title: value(params.Title), Notes: value(params.Notes), Draft: value(params.Draft), Prerelease: value(params.Prerelease), MakeLatest: value(params.MakeLatest)}) | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, err | |
| 182 | + | } | |
| 183 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", r.ID).Msg("github release created") | |
| 184 | + | return g.marshalRelease(ctx, p, r) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // GetRelease resolves an id, tag or latest selector. | |
| 188 | + | func (g *ReleasesGitHub) GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ports.ReleaseRef) ([]byte, error) { | |
| 189 | + | rn, err := g.repo(owner, repo) | |
| 190 | + | if err != nil { | |
| 191 | + | return nil, err | |
| 192 | + | } | |
| 193 | + | r, err := g.rel.GetRelease(ctx, p, rn, ref) | |
| 194 | + | if err != nil { | |
| 195 | + | return nil, err | |
| 196 | + | } | |
| 197 | + | return g.marshalRelease(ctx, p, r) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // UpdateRelease changes only present fields, including explicit false and empty strings. | |
| 201 | + | func (g *ReleasesGitHub) UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) { | |
| 202 | + | rn, err := g.repo(owner, repo) | |
| 203 | + | if err != nil { | |
| 204 | + | return nil, err | |
| 205 | + | } | |
| 206 | + | params, err := releaseParams(body) | |
| 207 | + | if err != nil { | |
| 208 | + | return nil, err | |
| 209 | + | } | |
| 210 | + | r, err := g.rel.UpdateRelease(ctx, p, rn, id, params) | |
| 211 | + | if err != nil { | |
| 212 | + | return nil, err | |
| 213 | + | } | |
| 214 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release updated") | |
| 215 | + | return g.marshalRelease(ctx, p, r) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // DeleteRelease removes a release and all its assets. | |
| 219 | + | func (g *ReleasesGitHub) DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error { | |
| 220 | + | rn, err := g.repo(owner, repo) | |
| 221 | + | if err != nil { | |
| 222 | + | return err | |
| 223 | + | } | |
| 224 | + | if err := g.rel.DeleteRelease(ctx, p, rn, id); err != nil { | |
| 225 | + | return err | |
| 226 | + | } | |
| 227 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release deleted") | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | func releasePagination(page, perPage int) (int, int) { | |
| 231 | + | if page < 1 { | |
| 232 | + | page = 1 | |
| 233 | + | } | |
| 234 | + | if perPage < 1 { | |
| 235 | + | perPage = 30 | |
| 236 | + | } | |
| 237 | + | return page, min(perPage, 100) | |
| 238 | + | } | |
| 239 | + | func releasePageBounds(page, perPage, total int) (int, int) { | |
| 240 | + | // Avoid overflow on arbitrary query parameters. | |
| 241 | + | if page-1 > total/perPage { | |
| 242 | + | return total, total | |
| 243 | + | } | |
| 244 | + | start := min((page-1)*perPage, total) | |
| 245 | + | return start, min(start+perPage, total) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // ListReleases returns visible releases newest first and GitHub Link pagination. | |
| 249 | + | func (g *ReleasesGitHub) ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) ([]byte, string, error) { | |
| 250 | + | rn, err := g.repo(owner, repo) | |
| 251 | + | if err != nil { | |
| 252 | + | return nil, "", err | |
| 253 | + | } | |
| 254 | + | if _, err = g.rel.readable(ctx, p, rn); err != nil { | |
| 255 | + | return nil, "", err | |
| 256 | + | } | |
| 257 | + | all, err := g.rel.store.ListReleases(ctx, rn) | |
| 258 | + | if err != nil { | |
| 259 | + | return nil, "", err | |
| 260 | + | } | |
| 261 | + | all = slices.DeleteFunc(all, func(r domain.Release) bool { return r.Draft && !releaseWriter(p, rn) }) | |
| 262 | + | slices.SortFunc(all, func(a, b domain.Release) int { | |
| 263 | + | if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 { | |
| 264 | + | return c | |
| 265 | + | } | |
| 266 | + | if a.ID > b.ID { | |
| 267 | + | return -1 | |
| 268 | + | } | |
| 269 | + | if a.ID < b.ID { | |
| 270 | + | return 1 | |
| 271 | + | } | |
| 272 | + | return 0 | |
| 273 | + | }) | |
| 274 | + | page, perPage = releasePagination(page, perPage) | |
| 275 | + | start, end := releasePageBounds(page, perPage, len(all)) | |
| 276 | + | objects := make([]ordered, 0, end-start) | |
| 277 | + | for _, r := range all[start:end] { | |
| 278 | + | o, e := g.releaseObj(ctx, p, r) | |
| 279 | + | if e != nil { | |
| 280 | + | return nil, "", e | |
| 281 | + | } | |
| 282 | + | objects = append(objects, o) | |
| 283 | + | } | |
| 284 | + | b, err := json.Marshal(objects) | |
| 285 | + | return b, pullFilesLink(g.base(rn)+"/releases", page, perPage, len(all)), err | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // ListAssets lists uploaded assets with the same pagination conventions. | |
| 289 | + | func (g *ReleasesGitHub) ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) ([]byte, string, error) { | |
| 290 | + | rn, err := g.repo(owner, repo) | |
| 291 | + | if err != nil { | |
| 292 | + | return nil, "", err | |
| 293 | + | } | |
| 294 | + | r, err := g.rel.GetRelease(ctx, p, rn, ports.ReleaseRef{ID: id}) | |
| 295 | + | if err != nil { | |
| 296 | + | return nil, "", err | |
| 297 | + | } | |
| 298 | + | all, err := g.rel.ListAssets(ctx, p, rn, id) | |
| 299 | + | if err != nil { | |
| 300 | + | return nil, "", err | |
| 301 | + | } | |
| 302 | + | page, perPage = releasePagination(page, perPage) | |
| 303 | + | start, end := releasePageBounds(page, perPage, len(all)) | |
| 304 | + | objects := make([]ordered, 0, end-start) | |
| 305 | + | for _, a := range all[start:end] { | |
| 306 | + | objects = append(objects, g.assetObj(a, r.Tag)) | |
| 307 | + | } | |
| 308 | + | b, err := json.Marshal(objects) | |
| 309 | + | return b, pullFilesLink(g.base(rn)+"/releases/"+strconv.FormatInt(id, 10)+"/assets", page, perPage, len(all)), err | |
| 310 | + | } | |
| 311 | + | func (g *ReleasesGitHub) marshalAsset(ctx context.Context, p domain.Principal, a domain.ReleaseAsset) ([]byte, error) { | |
| 312 | + | r, err := g.rel.GetRelease(ctx, p, a.Repo, ports.ReleaseRef{ID: a.ReleaseID}) | |
| 313 | + | if err != nil { | |
| 314 | + | return nil, err | |
| 315 | + | } | |
| 316 | + | return json.Marshal(g.assetObj(a, r.Tag)) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // UploadAsset streams an exact-sized raw body; duplicate names are not replaced. | |
| 320 | + | func (g *ReleasesGitHub) UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, body io.Reader) ([]byte, error) { | |
| 321 | + | rn, err := g.repo(owner, repo) | |
| 322 | + | if err != nil { | |
| 323 | + | return nil, err | |
| 324 | + | } | |
| 325 | + | a, err := g.rel.UploadAsset(ctx, p, rn, ports.UploadAssetParams{ReleaseID: id, Name: name, Label: label, ContentType: contentType, Size: size}, body) | |
| 326 | + | if err != nil { | |
| 327 | + | return nil, err | |
| 328 | + | } | |
| 329 | + | return g.marshalAsset(ctx, p, a) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // AssetByID resolves a visible asset, including its repository ownership. | |
| 333 | + | func (g *ReleasesGitHub) AssetByID(ctx context.Context, p domain.Principal, owner, repo string, id int64) (domain.ReleaseAsset, error) { | |
| 334 | + | rn, err := g.repo(owner, repo) | |
| 335 | + | if err != nil { | |
| 336 | + | return domain.ReleaseAsset{}, err | |
| 337 | + | } | |
| 338 | + | return g.rel.GetAsset(ctx, p, rn, ports.AssetRef{ID: id}) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // GetAsset returns the JSON representation, not the contents. | |
| 342 | + | func (g *ReleasesGitHub) GetAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) ([]byte, error) { | |
| 343 | + | a, err := g.AssetByID(ctx, p, owner, repo, id) | |
| 344 | + | if err != nil { | |
| 345 | + | return nil, err | |
| 346 | + | } | |
| 347 | + | return g.marshalAsset(ctx, p, a) | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // UpdateAsset changes the name and label fields that are present. | |
| 351 | + | func (g *ReleasesGitHub) UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) { | |
| 352 | + | rn, err := g.repo(owner, repo) | |
| 353 | + | if err != nil { | |
| 354 | + | return nil, err | |
| 355 | + | } | |
| 356 | + | f, err := releaseFields(body) | |
| 357 | + | if err != nil { | |
| 358 | + | return nil, err | |
| 359 | + | } | |
| 360 | + | name, err := releaseField[string](f, "name") | |
| 361 | + | if err != nil { | |
| 362 | + | return nil, err | |
| 363 | + | } | |
| 364 | + | label, err := releaseField[string](f, "label") | |
| 365 | + | if err != nil { | |
| 366 | + | return nil, err | |
| 367 | + | } | |
| 368 | + | a, err := g.rel.UpdateAsset(ctx, p, rn, id, ports.UpdateAssetParams{Name: name, Label: label}) | |
| 369 | + | if err != nil { | |
| 370 | + | return nil, err | |
| 371 | + | } | |
| 372 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset updated") | |
| 373 | + | return g.marshalAsset(ctx, p, a) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // DeleteAsset removes the asset record and its blob. | |
| 377 | + | func (g *ReleasesGitHub) DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) error { | |
| 378 | + | rn, err := g.repo(owner, repo) | |
| 379 | + | if err != nil { | |
| 380 | + | return err | |
| 381 | + | } | |
| 382 | + | if err := g.rel.DeleteAsset(ctx, p, rn, id); err != nil { | |
| 383 | + | return err | |
| 384 | + | } | |
| 385 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset deleted") | |
| 386 | + | return nil | |
| 387 | + | } | |
| @@ -0,0 +1,387 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "net/url" | |||
| 11 | + | "slices" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // ReleasesGitHub builds the GitHub REST representations of releases. Authorization | |||
| 20 | + | // and all mutations remain in Releases; it owns no metadata or contents. | |||
| 21 | + | type ReleasesGitHub struct { | |||
| 22 | + | rel *Releases | |||
| 23 | + | owner, publicURL string | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | var _ ports.GitHubReleaseAPI = (*ReleasesGitHub)(nil) | |||
| 27 | + | ||||
| 28 | + | // NewReleasesGitHub wraps the shared release service. publicURL is the external | |||
| 29 | + | // server URL (without the API prefix), used in upload and browser download URLs. | |||
| 30 | + | func NewReleasesGitHub(rel *Releases, owner, publicURL string) *ReleasesGitHub { | |||
| 31 | + | return &ReleasesGitHub{rel: rel, owner: owner, publicURL: strings.TrimRight(publicURL, "/")} | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (g *ReleasesGitHub) repo(owner, name string) (domain.RepoName, error) { | |||
| 35 | + | if !domain.OwnerMatches(g.owner, owner) { | |||
| 36 | + | return domain.RepoName{}, domain.ErrNotFound | |||
| 37 | + | } | |||
| 38 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return domain.RepoName{}, domain.ErrNotFound | |||
| 41 | + | } | |||
| 42 | + | return rn, nil | |||
| 43 | + | } | |||
| 44 | + | func (g *ReleasesGitHub) base(repo domain.RepoName) string { | |||
| 45 | + | return g.publicURL + apiPath + "/repos/" + url.PathEscape(g.owner) + "/" + url.PathEscape(repo.String()) | |||
| 46 | + | } | |||
| 47 | + | func (g *ReleasesGitHub) user(label string) map[string]any { | |||
| 48 | + | if label == "" || label == "root" || label == "owner" { | |||
| 49 | + | label = g.owner | |||
| 50 | + | } | |||
| 51 | + | u := ghURLs{server: g.publicURL, api: g.publicURL + apiPath} | |||
| 52 | + | return u.userObj(ghIdentity{login: label, id: stableID("user", label)}, false) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // escapeReleaseTag preserves tag separators: the web routes join the tag path. | |||
| 56 | + | func escapeReleaseTag(tag string) string { | |||
| 57 | + | parts := strings.Split(tag, "/") | |||
| 58 | + | for i := range parts { | |||
| 59 | + | parts[i] = url.PathEscape(parts[i]) | |||
| 60 | + | } | |||
| 61 | + | return strings.Join(parts, "/") | |||
| 62 | + | } | |||
| 63 | + | func (g *ReleasesGitHub) assetObj(a domain.ReleaseAsset, tag string) ordered { | |||
| 64 | + | return ordered{ | |||
| 65 | + | {"url", g.base(a.Repo) + "/releases/assets/" + strconv.FormatInt(a.ID, 10)}, | |||
| 66 | + | {"id", a.ID}, {"node_id", ""}, {"name", a.Name}, {"label", a.Label}, | |||
| 67 | + | {"uploader", g.user(a.Uploader)}, {"content_type", a.ContentType}, {"state", "uploaded"}, | |||
| 68 | + | {"size", a.Size}, {"digest", a.Digest}, {"download_count", 0}, | |||
| 69 | + | {"created_at", a.CreatedAt.UTC().Format(timeLayout)}, {"updated_at", a.UpdatedAt.UTC().Format(timeLayout)}, | |||
| 70 | + | {"browser_download_url", g.publicURL + "/" + a.Repo.String() + "/releases/download/" + escapeReleaseTag(tag) + "/" + url.PathEscape(a.Name)}, | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | func (g *ReleasesGitHub) releaseObj(ctx context.Context, p domain.Principal, r domain.Release) (ordered, error) { | |||
| 74 | + | assets, err := g.rel.ListAssets(ctx, p, r.Repo, r.ID) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return nil, err | |||
| 77 | + | } | |||
| 78 | + | objects := make([]ordered, 0, len(assets)) | |||
| 79 | + | for _, a := range assets { | |||
| 80 | + | objects = append(objects, g.assetObj(a, r.Tag)) | |||
| 81 | + | } | |||
| 82 | + | base := g.base(r.Repo) + "/releases/" + strconv.FormatInt(r.ID, 10) | |||
| 83 | + | var published any | |||
| 84 | + | if !r.Draft && !r.PublishedAt.IsZero() { | |||
| 85 | + | published = r.PublishedAt.UTC().Format(timeLayout) | |||
| 86 | + | } | |||
| 87 | + | return ordered{ | |||
| 88 | + | {"url", base}, {"assets_url", base + "/assets"}, | |||
| 89 | + | {"upload_url", g.publicURL + apiPath + "/uploads/repos/" + url.PathEscape(g.owner) + "/" + r.Repo.String() + "/releases/" + strconv.FormatInt(r.ID, 10) + "/assets{?name,label}"}, | |||
| 90 | + | {"html_url", g.publicURL + "/" + r.Repo.String() + "/releases/tag/" + escapeReleaseTag(r.Tag)}, | |||
| 91 | + | {"id", r.ID}, {"author", g.user(r.Author)}, {"node_id", ""}, {"tag_name", r.Tag}, {"target_commitish", r.TargetCommitish}, | |||
| 92 | + | {"name", r.Title}, {"draft", r.Draft}, {"prerelease", r.Prerelease}, {"created_at", r.CreatedAt.UTC().Format(timeLayout)}, | |||
| 93 | + | {"published_at", published}, {"assets", objects}, {"tarball_url", g.base(r.Repo) + "/tarball/" + escapeReleaseTag(r.Tag)}, | |||
| 94 | + | {"zipball_url", nil}, {"body", r.Notes}, | |||
| 95 | + | }, nil | |||
| 96 | + | } | |||
| 97 | + | func (g *ReleasesGitHub) marshalRelease(ctx context.Context, p domain.Principal, r domain.Release) ([]byte, error) { | |||
| 98 | + | o, err := g.releaseObj(ctx, p, r) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return nil, err | |||
| 101 | + | } | |||
| 102 | + | return json.Marshal(o) | |||
| 103 | + | } | |||
| 104 | + | func releaseFields(body []byte) (map[string]json.RawMessage, error) { | |||
| 105 | + | var fields map[string]json.RawMessage | |||
| 106 | + | if err := json.Unmarshal(body, &fields); err != nil || fields == nil { | |||
| 107 | + | return nil, fmt.Errorf("%w: JSON object required", domain.ErrInvalid) | |||
| 108 | + | } | |||
| 109 | + | return fields, nil | |||
| 110 | + | } | |||
| 111 | + | func releaseField[T any](fields map[string]json.RawMessage, key string) (*T, error) { | |||
| 112 | + | b, ok := fields[key] | |||
| 113 | + | if !ok { | |||
| 114 | + | return nil, nil | |||
| 115 | + | } | |||
| 116 | + | var v T | |||
| 117 | + | if string(b) == "null" || json.Unmarshal(b, &v) != nil { | |||
| 118 | + | return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, key) | |||
| 119 | + | } | |||
| 120 | + | return &v, nil | |||
| 121 | + | } | |||
| 122 | + | func releaseParams(body []byte) (ports.UpdateReleaseParams, error) { | |||
| 123 | + | var p ports.UpdateReleaseParams | |||
| 124 | + | f, err := releaseFields(body) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return p, err | |||
| 127 | + | } | |||
| 128 | + | for key, dst := range map[string]**string{"tag_name": &p.Tag, "target_commitish": &p.TargetCommitish, "name": &p.Title, "body": &p.Notes} { | |||
| 129 | + | *dst, err = releaseField[string](f, key) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return p, err | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | for key, dst := range map[string]**bool{"draft": &p.Draft, "prerelease": &p.Prerelease} { | |||
| 135 | + | *dst, err = releaseField[bool](f, key) | |||
| 136 | + | if err != nil { | |||
| 137 | + | return p, err | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | latest, err := releaseField[string](f, "make_latest") | |||
| 141 | + | if err != nil { | |||
| 142 | + | return p, err | |||
| 143 | + | } | |||
| 144 | + | if latest != nil { | |||
| 145 | + | switch *latest { | |||
| 146 | + | case "true": | |||
| 147 | + | v := true | |||
| 148 | + | p.MakeLatest = &v | |||
| 149 | + | case "false": | |||
| 150 | + | v := false | |||
| 151 | + | p.MakeLatest = &v | |||
| 152 | + | case "legacy": | |||
| 153 | + | default: | |||
| 154 | + | return p, fmt.Errorf("%w: make_latest", domain.ErrInvalid) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | return p, nil | |||
| 158 | + | } | |||
| 159 | + | func value[T any](p *T) (v T) { | |||
| 160 | + | if p != nil { | |||
| 161 | + | return *p | |||
| 162 | + | } | |||
| 163 | + | return v | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // CreateRelease accepts GitHub's create body and returns its representation. | |||
| 167 | + | func (g *ReleasesGitHub) CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) { | |||
| 168 | + | rn, err := g.repo(owner, repo) | |||
| 169 | + | if err != nil { | |||
| 170 | + | return nil, err | |||
| 171 | + | } | |||
| 172 | + | params, err := releaseParams(body) | |||
| 173 | + | if err != nil { | |||
| 174 | + | return nil, err | |||
| 175 | + | } | |||
| 176 | + | if params.Tag == nil || *params.Tag == "" { | |||
| 177 | + | return nil, fmt.Errorf("%w: tag_name", domain.ErrInvalid) | |||
| 178 | + | } | |||
| 179 | + | r, err := g.rel.CreateRelease(ctx, p, rn, ports.CreateReleaseParams{Tag: *params.Tag, TargetCommitish: value(params.TargetCommitish), Title: value(params.Title), Notes: value(params.Notes), Draft: value(params.Draft), Prerelease: value(params.Prerelease), MakeLatest: value(params.MakeLatest)}) | |||
| 180 | + | if err != nil { | |||
| 181 | + | return nil, err | |||
| 182 | + | } | |||
| 183 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", r.ID).Msg("github release created") | |||
| 184 | + | return g.marshalRelease(ctx, p, r) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // GetRelease resolves an id, tag or latest selector. | |||
| 188 | + | func (g *ReleasesGitHub) GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ports.ReleaseRef) ([]byte, error) { | |||
| 189 | + | rn, err := g.repo(owner, repo) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return nil, err | |||
| 192 | + | } | |||
| 193 | + | r, err := g.rel.GetRelease(ctx, p, rn, ref) | |||
| 194 | + | if err != nil { | |||
| 195 | + | return nil, err | |||
| 196 | + | } | |||
| 197 | + | return g.marshalRelease(ctx, p, r) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // UpdateRelease changes only present fields, including explicit false and empty strings. | |||
| 201 | + | func (g *ReleasesGitHub) UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) { | |||
| 202 | + | rn, err := g.repo(owner, repo) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return nil, err | |||
| 205 | + | } | |||
| 206 | + | params, err := releaseParams(body) | |||
| 207 | + | if err != nil { | |||
| 208 | + | return nil, err | |||
| 209 | + | } | |||
| 210 | + | r, err := g.rel.UpdateRelease(ctx, p, rn, id, params) | |||
| 211 | + | if err != nil { | |||
| 212 | + | return nil, err | |||
| 213 | + | } | |||
| 214 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release updated") | |||
| 215 | + | return g.marshalRelease(ctx, p, r) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // DeleteRelease removes a release and all its assets. | |||
| 219 | + | func (g *ReleasesGitHub) DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error { | |||
| 220 | + | rn, err := g.repo(owner, repo) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return err | |||
| 223 | + | } | |||
| 224 | + | if err := g.rel.DeleteRelease(ctx, p, rn, id); err != nil { | |||
| 225 | + | return err | |||
| 226 | + | } | |||
| 227 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release deleted") | |||
| 228 | + | return nil | |||
| 229 | + | } | |||
| 230 | + | func releasePagination(page, perPage int) (int, int) { | |||
| 231 | + | if page < 1 { | |||
| 232 | + | page = 1 | |||
| 233 | + | } | |||
| 234 | + | if perPage < 1 { | |||
| 235 | + | perPage = 30 | |||
| 236 | + | } | |||
| 237 | + | return page, min(perPage, 100) | |||
| 238 | + | } | |||
| 239 | + | func releasePageBounds(page, perPage, total int) (int, int) { | |||
| 240 | + | // Avoid overflow on arbitrary query parameters. | |||
| 241 | + | if page-1 > total/perPage { | |||
| 242 | + | return total, total | |||
| 243 | + | } | |||
| 244 | + | start := min((page-1)*perPage, total) | |||
| 245 | + | return start, min(start+perPage, total) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // ListReleases returns visible releases newest first and GitHub Link pagination. | |||
| 249 | + | func (g *ReleasesGitHub) ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) ([]byte, string, error) { | |||
| 250 | + | rn, err := g.repo(owner, repo) | |||
| 251 | + | if err != nil { | |||
| 252 | + | return nil, "", err | |||
| 253 | + | } | |||
| 254 | + | if _, err = g.rel.readable(ctx, p, rn); err != nil { | |||
| 255 | + | return nil, "", err | |||
| 256 | + | } | |||
| 257 | + | all, err := g.rel.store.ListReleases(ctx, rn) | |||
| 258 | + | if err != nil { | |||
| 259 | + | return nil, "", err | |||
| 260 | + | } | |||
| 261 | + | all = slices.DeleteFunc(all, func(r domain.Release) bool { return r.Draft && !releaseWriter(p, rn) }) | |||
| 262 | + | slices.SortFunc(all, func(a, b domain.Release) int { | |||
| 263 | + | if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 { | |||
| 264 | + | return c | |||
| 265 | + | } | |||
| 266 | + | if a.ID > b.ID { | |||
| 267 | + | return -1 | |||
| 268 | + | } | |||
| 269 | + | if a.ID < b.ID { | |||
| 270 | + | return 1 | |||
| 271 | + | } | |||
| 272 | + | return 0 | |||
| 273 | + | }) | |||
| 274 | + | page, perPage = releasePagination(page, perPage) | |||
| 275 | + | start, end := releasePageBounds(page, perPage, len(all)) | |||
| 276 | + | objects := make([]ordered, 0, end-start) | |||
| 277 | + | for _, r := range all[start:end] { | |||
| 278 | + | o, e := g.releaseObj(ctx, p, r) | |||
| 279 | + | if e != nil { | |||
| 280 | + | return nil, "", e | |||
| 281 | + | } | |||
| 282 | + | objects = append(objects, o) | |||
| 283 | + | } | |||
| 284 | + | b, err := json.Marshal(objects) | |||
| 285 | + | return b, pullFilesLink(g.base(rn)+"/releases", page, perPage, len(all)), err | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // ListAssets lists uploaded assets with the same pagination conventions. | |||
| 289 | + | func (g *ReleasesGitHub) ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) ([]byte, string, error) { | |||
| 290 | + | rn, err := g.repo(owner, repo) | |||
| 291 | + | if err != nil { | |||
| 292 | + | return nil, "", err | |||
| 293 | + | } | |||
| 294 | + | r, err := g.rel.GetRelease(ctx, p, rn, ports.ReleaseRef{ID: id}) | |||
| 295 | + | if err != nil { | |||
| 296 | + | return nil, "", err | |||
| 297 | + | } | |||
| 298 | + | all, err := g.rel.ListAssets(ctx, p, rn, id) | |||
| 299 | + | if err != nil { | |||
| 300 | + | return nil, "", err | |||
| 301 | + | } | |||
| 302 | + | page, perPage = releasePagination(page, perPage) | |||
| 303 | + | start, end := releasePageBounds(page, perPage, len(all)) | |||
| 304 | + | objects := make([]ordered, 0, end-start) | |||
| 305 | + | for _, a := range all[start:end] { | |||
| 306 | + | objects = append(objects, g.assetObj(a, r.Tag)) | |||
| 307 | + | } | |||
| 308 | + | b, err := json.Marshal(objects) | |||
| 309 | + | return b, pullFilesLink(g.base(rn)+"/releases/"+strconv.FormatInt(id, 10)+"/assets", page, perPage, len(all)), err | |||
| 310 | + | } | |||
| 311 | + | func (g *ReleasesGitHub) marshalAsset(ctx context.Context, p domain.Principal, a domain.ReleaseAsset) ([]byte, error) { | |||
| 312 | + | r, err := g.rel.GetRelease(ctx, p, a.Repo, ports.ReleaseRef{ID: a.ReleaseID}) | |||
| 313 | + | if err != nil { | |||
| 314 | + | return nil, err | |||
| 315 | + | } | |||
| 316 | + | return json.Marshal(g.assetObj(a, r.Tag)) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // UploadAsset streams an exact-sized raw body; duplicate names are not replaced. | |||
| 320 | + | func (g *ReleasesGitHub) UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, body io.Reader) ([]byte, error) { | |||
| 321 | + | rn, err := g.repo(owner, repo) | |||
| 322 | + | if err != nil { | |||
| 323 | + | return nil, err | |||
| 324 | + | } | |||
| 325 | + | a, err := g.rel.UploadAsset(ctx, p, rn, ports.UploadAssetParams{ReleaseID: id, Name: name, Label: label, ContentType: contentType, Size: size}, body) | |||
| 326 | + | if err != nil { | |||
| 327 | + | return nil, err | |||
| 328 | + | } | |||
| 329 | + | return g.marshalAsset(ctx, p, a) | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | // AssetByID resolves a visible asset, including its repository ownership. | |||
| 333 | + | func (g *ReleasesGitHub) AssetByID(ctx context.Context, p domain.Principal, owner, repo string, id int64) (domain.ReleaseAsset, error) { | |||
| 334 | + | rn, err := g.repo(owner, repo) | |||
| 335 | + | if err != nil { | |||
| 336 | + | return domain.ReleaseAsset{}, err | |||
| 337 | + | } | |||
| 338 | + | return g.rel.GetAsset(ctx, p, rn, ports.AssetRef{ID: id}) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // GetAsset returns the JSON representation, not the contents. | |||
| 342 | + | func (g *ReleasesGitHub) GetAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) ([]byte, error) { | |||
| 343 | + | a, err := g.AssetByID(ctx, p, owner, repo, id) | |||
| 344 | + | if err != nil { | |||
| 345 | + | return nil, err | |||
| 346 | + | } | |||
| 347 | + | return g.marshalAsset(ctx, p, a) | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // UpdateAsset changes the name and label fields that are present. | |||
| 351 | + | func (g *ReleasesGitHub) UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) { | |||
| 352 | + | rn, err := g.repo(owner, repo) | |||
| 353 | + | if err != nil { | |||
| 354 | + | return nil, err | |||
| 355 | + | } | |||
| 356 | + | f, err := releaseFields(body) | |||
| 357 | + | if err != nil { | |||
| 358 | + | return nil, err | |||
| 359 | + | } | |||
| 360 | + | name, err := releaseField[string](f, "name") | |||
| 361 | + | if err != nil { | |||
| 362 | + | return nil, err | |||
| 363 | + | } | |||
| 364 | + | label, err := releaseField[string](f, "label") | |||
| 365 | + | if err != nil { | |||
| 366 | + | return nil, err | |||
| 367 | + | } | |||
| 368 | + | a, err := g.rel.UpdateAsset(ctx, p, rn, id, ports.UpdateAssetParams{Name: name, Label: label}) | |||
| 369 | + | if err != nil { | |||
| 370 | + | return nil, err | |||
| 371 | + | } | |||
| 372 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset updated") | |||
| 373 | + | return g.marshalAsset(ctx, p, a) | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // DeleteAsset removes the asset record and its blob. | |||
| 377 | + | func (g *ReleasesGitHub) DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) error { | |||
| 378 | + | rn, err := g.repo(owner, repo) | |||
| 379 | + | if err != nil { | |||
| 380 | + | return err | |||
| 381 | + | } | |||
| 382 | + | if err := g.rel.DeleteAsset(ctx, p, rn, id); err != nil { | |||
| 383 | + | return err | |||
| 384 | + | } | |||
| 385 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset deleted") | |||
| 386 | + | return nil | |||
| 387 | + | } | |||
A internal/usecase/releases_github_test.go +347 -0
| @@ -0,0 +1,347 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 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 | + | ||
| 28 | + | func releaseGitHub(t *testing.T, r *releaseRig) *usecase.ReleasesGitHub { | |
| 29 | + | t.Helper() | |
| 30 | + | return usecase.NewReleasesGitHub(r.svc, "acme", "http://localhost:7341/") | |
| 31 | + | } | |
| 32 | + | func 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 | + | } | |
| 46 | + | func 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 | + | } | |
| 64 | + | func 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 | + | } | |
| 109 | + | func 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 | + | } | |
| 149 | + | func 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/slash/%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 | + | ||
| 197 | + | func 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 | + | ||
| 246 | + | func 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 | + | ||
| 278 | + | // A real HTTP round trip through the facade, metadata store and disk blob | |
| 279 | + | // adapter protects the goreleaser/softprops create, retry, overwrite, publish | |
| 280 | + | // sequence, not just each side's mocked contract. | |
| 281 | + | func TestGitHubReleaseHTTPWorkflow(t *testing.T) { | |
| 282 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 283 | + | g := releaseGitHub(t, r) | |
| 284 | + | auth := mocks.NewAuthService(t) | |
| 285 | + | auth.EXPECT().Authenticate(mock.Anything, "dev-token").Return(domain.Root(), nil).Maybe() | |
| 286 | + | srv := httptest.NewServer(githubapi.New(githubapi.Config{Auth: auth, Releases: g, ReleaseDownload: r.svc})) | |
| 287 | + | defer srv.Close() | |
| 288 | + | do := func(method, path, body string, headers map[string]string) (*http.Response, string) { | |
| 289 | + | req, err := http.NewRequestWithContext(r.ctx, method, srv.URL+githubapi.Prefix+path, strings.NewReader(body)) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | req.Header.Set("Authorization", "Bearer dev-token") | |
| 292 | + | for k, v := range headers { | |
| 293 | + | req.Header.Set(k, v) | |
| 294 | + | } | |
| 295 | + | resp, err := srv.Client().Do(req) | |
| 296 | + | require.NoError(t, err) | |
| 297 | + | defer resp.Body.Close() | |
| 298 | + | b, err := io.ReadAll(resp.Body) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | return resp, string(b) | |
| 301 | + | } | |
| 302 | + | base := "/repos/acme/" + r.repo.String() + "/releases" | |
| 303 | + | resp, _ := do("GET", base+"/tags/v1", "", nil) | |
| 304 | + | require.Equal(t, 404, resp.StatusCode) | |
| 305 | + | r.resolve(t, "v1", nil) | |
| 306 | + | resp, b := do("POST", base, `{"tag_name":"v1","draft":true}`, nil) | |
| 307 | + | require.Equal(t, 201, resp.StatusCode) | |
| 308 | + | var rel struct { | |
| 309 | + | ID int64 `json:"id"` | |
| 310 | + | UploadURL string `json:"upload_url"` | |
| 311 | + | } | |
| 312 | + | require.NoError(t, json.Unmarshal([]byte(b), &rel)) | |
| 313 | + | upload := strings.TrimPrefix(strings.Split(rel.UploadURL, "{")[0], "http://localhost:7341"+githubapi.Prefix) + "?name=archive.tar.gz" | |
| 314 | + | resp, b = do("POST", upload, "0123456789", map[string]string{"Content-Type": "application/gzip"}) | |
| 315 | + | require.Equal(t, 201, resp.StatusCode) | |
| 316 | + | var a struct { | |
| 317 | + | ID int64 `json:"id"` | |
| 318 | + | } | |
| 319 | + | require.NoError(t, json.Unmarshal([]byte(b), &a)) | |
| 320 | + | resp, b = do("POST", upload, "replacement", nil) | |
| 321 | + | require.Equal(t, 422, resp.StatusCode) | |
| 322 | + | require.Contains(t, b, `"code":"already_exists"`) | |
| 323 | + | resp, b = do("GET", base+"/"+strconv.FormatInt(rel.ID, 10)+"/assets", "", nil) | |
| 324 | + | require.Equal(t, 200, resp.StatusCode) | |
| 325 | + | require.Contains(t, b, `"name":"archive.tar.gz"`) | |
| 326 | + | assetURL := base + "/assets/" + strconv.FormatInt(a.ID, 10) | |
| 327 | + | resp, _ = do("DELETE", assetURL, "", nil) | |
| 328 | + | require.Equal(t, 204, resp.StatusCode) | |
| 329 | + | resp, b = do("POST", upload, "abcdefghij", nil) | |
| 330 | + | require.Equal(t, 201, resp.StatusCode) | |
| 331 | + | require.NoError(t, json.Unmarshal([]byte(b), &a)) | |
| 332 | + | resp, _ = do("PATCH", base+"/"+strconv.FormatInt(rel.ID, 10), `{"draft":false}`, nil) | |
| 333 | + | require.Equal(t, 200, resp.StatusCode) | |
| 334 | + | assetURL = base + "/assets/" + strconv.FormatInt(a.ID, 10) | |
| 335 | + | resp, b = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5"}) | |
| 336 | + | require.Equal(t, 206, resp.StatusCode) | |
| 337 | + | require.Equal(t, "cdef", b) | |
| 338 | + | require.Equal(t, "bytes 2-5/10", resp.Header.Get("Content-Range")) | |
| 339 | + | require.Equal(t, "4", resp.Header.Get("Content-Length")) | |
| 340 | + | require.Empty(t, resp.Header.Get("Location")) | |
| 341 | + | resp, b = do("HEAD", assetURL, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 342 | + | require.Equal(t, 200, resp.StatusCode) | |
| 343 | + | require.Empty(t, b) | |
| 344 | + | require.Equal(t, "10", resp.Header.Get("Content-Length")) | |
| 345 | + | resp, _ = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "If-None-Match": resp.Header.Get("ETag")}) | |
| 346 | + | require.Equal(t, 304, resp.StatusCode) | |
| 347 | + | } | |
| @@ -0,0 +1,347 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 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 | + | ||||
| 28 | + | func releaseGitHub(t *testing.T, r *releaseRig) *usecase.ReleasesGitHub { | |||
| 29 | + | t.Helper() | |||
| 30 | + | return usecase.NewReleasesGitHub(r.svc, "acme", "http://localhost:7341/") | |||
| 31 | + | } | |||
| 32 | + | func 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 | + | } | |||
| 46 | + | func 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 | + | } | |||
| 64 | + | func 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 | + | } | |||
| 109 | + | func 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 | + | } | |||
| 149 | + | func 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/slash/%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 | + | ||||
| 197 | + | func 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 | + | ||||
| 246 | + | func 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 | + | ||||
| 278 | + | // A real HTTP round trip through the facade, metadata store and disk blob | |||
| 279 | + | // adapter protects the goreleaser/softprops create, retry, overwrite, publish | |||
| 280 | + | // sequence, not just each side's mocked contract. | |||
| 281 | + | func TestGitHubReleaseHTTPWorkflow(t *testing.T) { | |||
| 282 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |||
| 283 | + | g := releaseGitHub(t, r) | |||
| 284 | + | auth := mocks.NewAuthService(t) | |||
| 285 | + | auth.EXPECT().Authenticate(mock.Anything, "dev-token").Return(domain.Root(), nil).Maybe() | |||
| 286 | + | srv := httptest.NewServer(githubapi.New(githubapi.Config{Auth: auth, Releases: g, ReleaseDownload: r.svc})) | |||
| 287 | + | defer srv.Close() | |||
| 288 | + | do := func(method, path, body string, headers map[string]string) (*http.Response, string) { | |||
| 289 | + | req, err := http.NewRequestWithContext(r.ctx, method, srv.URL+githubapi.Prefix+path, strings.NewReader(body)) | |||
| 290 | + | require.NoError(t, err) | |||
| 291 | + | req.Header.Set("Authorization", "Bearer dev-token") | |||
| 292 | + | for k, v := range headers { | |||
| 293 | + | req.Header.Set(k, v) | |||
| 294 | + | } | |||
| 295 | + | resp, err := srv.Client().Do(req) | |||
| 296 | + | require.NoError(t, err) | |||
| 297 | + | defer resp.Body.Close() | |||
| 298 | + | b, err := io.ReadAll(resp.Body) | |||
| 299 | + | require.NoError(t, err) | |||
| 300 | + | return resp, string(b) | |||
| 301 | + | } | |||
| 302 | + | base := "/repos/acme/" + r.repo.String() + "/releases" | |||
| 303 | + | resp, _ := do("GET", base+"/tags/v1", "", nil) | |||
| 304 | + | require.Equal(t, 404, resp.StatusCode) | |||
| 305 | + | r.resolve(t, "v1", nil) | |||
| 306 | + | resp, b := do("POST", base, `{"tag_name":"v1","draft":true}`, nil) | |||
| 307 | + | require.Equal(t, 201, resp.StatusCode) | |||
| 308 | + | var rel struct { | |||
| 309 | + | ID int64 `json:"id"` | |||
| 310 | + | UploadURL string `json:"upload_url"` | |||
| 311 | + | } | |||
| 312 | + | require.NoError(t, json.Unmarshal([]byte(b), &rel)) | |||
| 313 | + | upload := strings.TrimPrefix(strings.Split(rel.UploadURL, "{")[0], "http://localhost:7341"+githubapi.Prefix) + "?name=archive.tar.gz" | |||
| 314 | + | resp, b = do("POST", upload, "0123456789", map[string]string{"Content-Type": "application/gzip"}) | |||
| 315 | + | require.Equal(t, 201, resp.StatusCode) | |||
| 316 | + | var a struct { | |||
| 317 | + | ID int64 `json:"id"` | |||
| 318 | + | } | |||
| 319 | + | require.NoError(t, json.Unmarshal([]byte(b), &a)) | |||
| 320 | + | resp, b = do("POST", upload, "replacement", nil) | |||
| 321 | + | require.Equal(t, 422, resp.StatusCode) | |||
| 322 | + | require.Contains(t, b, `"code":"already_exists"`) | |||
| 323 | + | resp, b = do("GET", base+"/"+strconv.FormatInt(rel.ID, 10)+"/assets", "", nil) | |||
| 324 | + | require.Equal(t, 200, resp.StatusCode) | |||
| 325 | + | require.Contains(t, b, `"name":"archive.tar.gz"`) | |||
| 326 | + | assetURL := base + "/assets/" + strconv.FormatInt(a.ID, 10) | |||
| 327 | + | resp, _ = do("DELETE", assetURL, "", nil) | |||
| 328 | + | require.Equal(t, 204, resp.StatusCode) | |||
| 329 | + | resp, b = do("POST", upload, "abcdefghij", nil) | |||
| 330 | + | require.Equal(t, 201, resp.StatusCode) | |||
| 331 | + | require.NoError(t, json.Unmarshal([]byte(b), &a)) | |||
| 332 | + | resp, _ = do("PATCH", base+"/"+strconv.FormatInt(rel.ID, 10), `{"draft":false}`, nil) | |||
| 333 | + | require.Equal(t, 200, resp.StatusCode) | |||
| 334 | + | assetURL = base + "/assets/" + strconv.FormatInt(a.ID, 10) | |||
| 335 | + | resp, b = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5"}) | |||
| 336 | + | require.Equal(t, 206, resp.StatusCode) | |||
| 337 | + | require.Equal(t, "cdef", b) | |||
| 338 | + | require.Equal(t, "bytes 2-5/10", resp.Header.Get("Content-Range")) | |||
| 339 | + | require.Equal(t, "4", resp.Header.Get("Content-Length")) | |||
| 340 | + | require.Empty(t, resp.Header.Get("Location")) | |||
| 341 | + | resp, b = do("HEAD", assetURL, "", map[string]string{"Accept": "application/octet-stream"}) | |||
| 342 | + | require.Equal(t, 200, resp.StatusCode) | |||
| 343 | + | require.Empty(t, b) | |||
| 344 | + | require.Equal(t, "10", resp.Header.Get("Content-Length")) | |||
| 345 | + | resp, _ = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "If-None-Match": resp.Header.Get("ETag")}) | |||
| 346 | + | require.Equal(t, 304, resp.StatusCode) | |||
| 347 | + | } | |||