Add Gitea read operations to the forge boundary
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Implementation plan: neutral forge delivery and credential preflight
|
# Implementation plan: neutral forge delivery and credential preflight
|
||||||
|
|
||||||
Status: implementation in progress; Stages 1–2 are complete and Stages 3–10
|
Status: implementation in progress; Stages 1–3 are complete and Stages 4–10
|
||||||
are pending.
|
are pending.
|
||||||
|
|
||||||
## Purpose and execution
|
## Purpose and execution
|
||||||
@@ -266,7 +266,7 @@ adding a guarantee against concurrent file replacement.
|
|||||||
|
|
||||||
## Stage 3: Extract Gitea read operations
|
## Stage 3: Extract Gitea read operations
|
||||||
|
|
||||||
Status: pending.
|
Status: complete.
|
||||||
|
|
||||||
1. Adapt the Gitea client into a backend configured with its private destination
|
1. Adapt the Gitea client into a backend configured with its private destination
|
||||||
and optional HTTP client. Implement `TagCommit`, `FindRelease`, `ListAssets`,
|
and optional HTTP client. Implement `TagCommit`, `FindRelease`, `ListAssets`,
|
||||||
|
|||||||
@@ -69,22 +69,13 @@ func (c Client) InspectAssets(ctx context.Context, destination Destination, rele
|
|||||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, err)
|
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, err)
|
||||||
}
|
}
|
||||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||||
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
listed, err := c.listAssets(ctx, c.httpClient(), destination.Token, releaseID, apiRoot)
|
||||||
var listed []assetResponse
|
if err != nil {
|
||||||
if err := c.getJSON(ctx, c.httpClient(), destination.Token, "list release assets", assetsURL, &listed); err != nil {
|
|
||||||
return Inspection{}, err
|
return Inspection{}, err
|
||||||
}
|
}
|
||||||
if listed == nil {
|
|
||||||
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteByName := make(map[string][]RemoteAsset, len(listed))
|
remoteByName := make(map[string][]RemoteAsset, len(listed))
|
||||||
for _, item := range listed {
|
for _, item := range listed {
|
||||||
if item.ID <= 0 || item.Name == "" || item.Size < 0 {
|
remoteByName[item.Filename] = append(remoteByName[item.Filename], item)
|
||||||
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
|
|
||||||
}
|
|
||||||
remote := RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL}
|
|
||||||
remoteByName[item.Name] = append(remoteByName[item.Name], remote)
|
|
||||||
}
|
}
|
||||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
||||||
declared := make(map[string]struct{}, len(expected))
|
declared := make(map[string]struct{}, len(expected))
|
||||||
@@ -111,32 +102,68 @@ func (c Client) InspectAssets(ctx context.Context, destination Destination, rele
|
|||||||
inspection.Assets = append(inspection.Assets, observation)
|
inspection.Assets = append(inspection.Assets, observation)
|
||||||
}
|
}
|
||||||
for _, item := range listed {
|
for _, item := range listed {
|
||||||
if _, expected := declared[item.Name]; !expected {
|
if _, expected := declared[item.Filename]; !expected {
|
||||||
inspection.Unrelated = append(inspection.Unrelated, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
|
inspection.Unrelated = append(inspection.Unrelated, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return inspection, nil
|
return inspection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) {
|
||||||
|
if releaseID <= 0 {
|
||||||
|
return nil, failure(FailureInvalid, "list release assets", 0, errors.New("release ID must be positive"))
|
||||||
|
}
|
||||||
|
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||||
|
var listed []assetResponse
|
||||||
|
if err := c.getJSON(ctx, client, token, "list release assets", assetsURL, &listed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if listed == nil {
|
||||||
|
return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
|
||||||
|
}
|
||||||
|
assets := make([]RemoteAsset, 0, len(listed))
|
||||||
|
for _, item := range listed {
|
||||||
|
if item.ID <= 0 || item.Name == "" || item.Size < 0 {
|
||||||
|
return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
|
||||||
|
}
|
||||||
|
assets = append(assets, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
|
||||||
|
}
|
||||||
|
return assets, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) {
|
func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) {
|
||||||
|
stream, err := c.openAsset(ctx, client, base, token, rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
hasher := sha256.New()
|
||||||
|
size, err := io.Copy(hasher, stream)
|
||||||
|
if err != nil {
|
||||||
|
return "", size, failure(FailureUnverifiable, "read release asset", 0, err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil)), size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (io.ReadCloser, error) {
|
||||||
if strings.TrimSpace(rawURL) == "" {
|
if strings.TrimSpace(rawURL) == "" {
|
||||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
||||||
}
|
}
|
||||||
assetURL, err := base.Parse(rawURL)
|
assetURL, err := base.Parse(rawURL)
|
||||||
if err != nil || assetURL.User != nil {
|
if err != nil || assetURL.User != nil {
|
||||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL"))
|
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL"))
|
||||||
}
|
}
|
||||||
assetURL.Fragment = ""
|
assetURL.Fragment = ""
|
||||||
visited := make(map[string]struct{})
|
visited := make(map[string]struct{})
|
||||||
for {
|
for {
|
||||||
key := assetURL.String()
|
key := assetURL.String()
|
||||||
if _, exists := visited[key]; exists {
|
if _, exists := visited[key]; exists {
|
||||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
|
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
|
||||||
}
|
}
|
||||||
visited[key] = struct{}{}
|
visited[key] = struct{}{}
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
|
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||||
}
|
}
|
||||||
if sameOrigin(base, assetURL) {
|
if sameOrigin(base, assetURL) {
|
||||||
request.Header.Set("Authorization", "token "+token)
|
request.Header.Set("Authorization", "token "+token)
|
||||||
@@ -144,28 +171,23 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
|
|||||||
request.Header.Set("Accept", "application/octet-stream")
|
request.Header.Set("Accept", "application/octet-stream")
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
|
return nil, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||||
}
|
}
|
||||||
if isRedirect(response.StatusCode) {
|
if isRedirect(response.StatusCode) {
|
||||||
location, locationErr := response.Location()
|
location, locationErr := response.Location()
|
||||||
_ = response.Body.Close()
|
_ = response.Body.Close()
|
||||||
if locationErr != nil || location.User != nil {
|
if locationErr != nil || location.User != nil {
|
||||||
return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
|
return nil, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
|
||||||
}
|
}
|
||||||
location.Fragment = ""
|
location.Fragment = ""
|
||||||
assetURL = location
|
assetURL = location
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
return "", 0, responseFailure("download release asset", response.StatusCode)
|
_ = response.Body.Close()
|
||||||
|
return nil, responseFailure("download release asset", response.StatusCode)
|
||||||
}
|
}
|
||||||
hasher := sha256.New()
|
return response.Body, nil
|
||||||
size, err := io.Copy(hasher, response.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", size, failure(FailureUnverifiable, "read release asset", response.StatusCode, err)
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(hasher.Sum(nil)), size, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
164
internal/gitea/backend.go
Normal file
164
internal/gitea/backend.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backend adapts a configured Gitea destination to the hosted delivery
|
||||||
|
// operations. Its destination and client are private so callers cannot bypass
|
||||||
|
// Gitea's validation or replace opaque handles.
|
||||||
|
type Backend struct {
|
||||||
|
destination Destination
|
||||||
|
client Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackend creates a Gitea backend with an optional HTTP client.
|
||||||
|
func NewBackend(destination Destination, client *http.Client) *Backend {
|
||||||
|
return &Backend{destination: destination, client: Client{HTTPClient: client}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseHandle struct {
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetHandle struct {
|
||||||
|
id int64
|
||||||
|
downloadURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) TagCommit(ctx context.Context, tag string) (string, error) {
|
||||||
|
if strings.TrimSpace(tag) == "" {
|
||||||
|
return "", &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
|
||||||
|
}
|
||||||
|
_, apiRoot, err := b.destinationAPI("get hosted tag")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var observed tagResponse
|
||||||
|
endpoint := apiRoot + "/tags/" + url.PathEscape(tag)
|
||||||
|
if err := b.client.getJSON(ctx, b.client.httpClient(), b.destination.Token, "get hosted tag", endpoint, &observed); err != nil {
|
||||||
|
return "", neutralFailure(err)
|
||||||
|
}
|
||||||
|
if observed.Commit.SHA == "" {
|
||||||
|
return "", &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("tag response does not identify a commit")}
|
||||||
|
}
|
||||||
|
return observed.Commit.SHA, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) FindRelease(ctx context.Context, tag string) (forge.Release, error) {
|
||||||
|
if strings.TrimSpace(tag) == "" {
|
||||||
|
return forge.Release{}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
|
||||||
|
}
|
||||||
|
_, apiRoot, err := b.destinationAPI("find release")
|
||||||
|
if err != nil {
|
||||||
|
return forge.Release{}, err
|
||||||
|
}
|
||||||
|
endpoint := apiRoot + "/releases/tags/" + url.PathEscape(tag)
|
||||||
|
release, _, err := b.client.getRelease(ctx, b.client.httpClient(), b.destination.Token, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return forge.Release{}, neutralFailure(err)
|
||||||
|
}
|
||||||
|
return forge.Release{
|
||||||
|
URL: release.URL,
|
||||||
|
Tag: release.TagName,
|
||||||
|
Title: release.Title,
|
||||||
|
Body: release.Body,
|
||||||
|
Draft: release.Draft,
|
||||||
|
Prerelease: release.Prerelease,
|
||||||
|
Handle: releaseHandle{id: release.ID},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) ListAssets(ctx context.Context, handle any) ([]forge.Asset, error) {
|
||||||
|
release, ok := handle.(releaseHandle)
|
||||||
|
if !ok {
|
||||||
|
return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")}
|
||||||
|
}
|
||||||
|
_, apiRoot, err := b.destinationAPI("list release assets")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remote, err := b.client.listAssets(ctx, b.client.httpClient(), b.destination.Token, release.id, apiRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, neutralFailure(err)
|
||||||
|
}
|
||||||
|
assets := make([]forge.Asset, 0, len(remote))
|
||||||
|
for _, item := range remote {
|
||||||
|
assets = append(assets, forge.Asset{
|
||||||
|
Filename: item.Filename,
|
||||||
|
Size: item.Size,
|
||||||
|
Handle: assetHandle{id: item.ID, downloadURL: item.DownloadURL},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return assets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) OpenAsset(ctx context.Context, handle any) (io.ReadCloser, error) {
|
||||||
|
asset, ok := handle.(assetHandle)
|
||||||
|
if !ok {
|
||||||
|
return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset handle was not created by the Gitea backend")}
|
||||||
|
}
|
||||||
|
base, _, err := b.destinationAPI("download release asset")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stream, err := b.client.openAsset(ctx, b.client.httpClient(), base, b.destination.Token, asset.downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, neutralFailure(err)
|
||||||
|
}
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) destinationAPI(operation string) (*url.URL, string, error) {
|
||||||
|
base, err := parseBaseURL(b.destination.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, err))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.destination.Token) == "" {
|
||||||
|
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea token is required")))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.destination.Owner) == "" || strings.ContainsAny(b.destination.Owner, "/\\") {
|
||||||
|
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea owner must be one path segment")))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.destination.Repository) == "" || strings.ContainsAny(b.destination.Repository, "/\\") {
|
||||||
|
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea repository must be one path segment")))
|
||||||
|
}
|
||||||
|
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(b.destination.Owner) + "/" + url.PathEscape(b.destination.Repository)
|
||||||
|
return base, apiRoot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func neutralFailure(err error) error {
|
||||||
|
var giteaFailure *Failure
|
||||||
|
if !errors.As(err, &giteaFailure) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kind := forge.FailureUnknown
|
||||||
|
switch giteaFailure.Kind {
|
||||||
|
case FailureInvalid:
|
||||||
|
kind = forge.FailureInvalid
|
||||||
|
case FailureNotFound:
|
||||||
|
kind = forge.FailureNotFound
|
||||||
|
case FailureUnauthorized:
|
||||||
|
kind = forge.FailureUnauthorized
|
||||||
|
case FailureConflict:
|
||||||
|
kind = forge.FailureConflict
|
||||||
|
case FailureMalformed:
|
||||||
|
kind = forge.FailureMalformed
|
||||||
|
case FailureUnverifiable:
|
||||||
|
kind = forge.FailureUnverifiable
|
||||||
|
case FailureTransport:
|
||||||
|
kind = forge.FailureTransport
|
||||||
|
case FailureRejected:
|
||||||
|
kind = forge.FailureRejected
|
||||||
|
case FailureUnknown:
|
||||||
|
kind = forge.FailureUnknown
|
||||||
|
}
|
||||||
|
return &forge.Failure{Kind: kind, Err: err}
|
||||||
|
}
|
||||||
116
internal/gitea/backend_test.go
Normal file
116
internal/gitea/backend_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackendTagCommitUsesEscapedFullTagAndAuthentication(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got, want := r.RequestURI, "/api/v1/repos/owner/project/tags/v1.2.3%2Frc.1"; got != want {
|
||||||
|
t.Errorf("request URI = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
|
t.Errorf("authorization = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"commit": map[string]string{"sha": "abcdef"}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
commit, err := backend.TagCommit(context.Background(), "v1.2.3/rc.1")
|
||||||
|
if err != nil || commit != "abcdef" {
|
||||||
|
t.Fatalf("TagCommit() = %q, %v", commit, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendFindReleaseReturnsNeutralNotFound(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
_, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureNotFound {
|
||||||
|
t.Fatalf("FindRelease() error = %v, want neutral not-found failure", err)
|
||||||
|
}
|
||||||
|
var cause *Failure
|
||||||
|
if !errors.As(err, &cause) || cause.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("FindRelease() error cause = %v, want Gitea 404 diagnostic", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendFindReleaseDoesNotRequireWriteResponseFields(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3", Name: "Release"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if release.Tag != "v1.2.3" || release.Title != "Release" || release.Handle == nil {
|
||||||
|
t.Fatalf("FindRelease() = %#v", release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendListsMetadataAndOpensCallerOwnedAssetStream(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
|
t.Errorf("authorization = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||||
|
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "v1.2.3"})
|
||||||
|
case "/api/v1/repos/owner/project/releases/12/assets":
|
||||||
|
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
||||||
|
case "/files/app":
|
||||||
|
_, _ = io.WriteString(w, "data")
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||||
|
|
||||||
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assets, err := backend.ListAssets(context.Background(), release.Handle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
|
||||||
|
t.Fatalf("ListAssets() = %#v", assets)
|
||||||
|
}
|
||||||
|
stream, err := backend.OpenAsset(context.Background(), assets[0].Handle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, readErr := io.ReadAll(stream)
|
||||||
|
closeErr := stream.Close()
|
||||||
|
if readErr != nil || closeErr != nil || string(content) != "data" {
|
||||||
|
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) {
|
||||||
|
backend := NewBackend(testDestination("http://example.test"), nil)
|
||||||
|
_, err := backend.OpenAsset(context.Background(), strings.NewReader("asset"))
|
||||||
|
var failure *forge.Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid {
|
||||||
|
t.Fatalf("OpenAsset() error = %v, want invalid handle", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user