Complete forge delivery regression coverage
This commit is contained in:
@@ -1029,6 +1029,227 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsMissingBlankAndUnsupportedForgeBeforeTokenOrBuild(t *testing.T) {
|
||||
for _, forge := range []string{"", " \t", "unknown"} {
|
||||
t.Run(forge, func(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: forge, BaseURL: server.URL, Owner: "owner", Repository: "example",
|
||||
TokenFile: filepath.Join(repository, "missing-token"),
|
||||
},
|
||||
})
|
||||
state.update(func(data *deliveryServerData) { data.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 2 || !strings.Contains(stderr.String(), "forge") || strings.Contains(stderr.String(), "missing-token") {
|
||||
t.Fatalf("deliver status=%d stdout=%q stderr=%q; want selection diagnostic before token access", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Go was invoked before forge validation: %v", err)
|
||||
}
|
||||
if got := state.snapshot().apiRequests; got != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDeliverRejectsUnsupportedForgeBeforeTokenOrDependencyPreparation(t *testing.T) {
|
||||
repository := newDeliveryBuildRepository(t)
|
||||
state := &deliveryServerState{}
|
||||
server := newDeliveryServer(t, state)
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: "unsupported", BaseURL: server.URL, Owner: "owner", Repository: "example",
|
||||
TokenFile: filepath.Join(repository, "missing-token"),
|
||||
},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.update(func(data *deliveryServerData) { data.commit = expectedCommit })
|
||||
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 2 || !strings.Contains(stderr.String(), "forge") || strings.Contains(stderr.String(), "missing-token") {
|
||||
t.Fatalf("ci-deliver status=%d stdout=%q stderr=%q; want selection diagnostic before token access", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Go was invoked before forge validation: %v", err)
|
||||
}
|
||||
if got := state.snapshot().apiRequests; got != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
var mu sync.Mutex
|
||||
var requests, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
requests++
|
||||
if r.URL.Path == "/api/v1/repos/owner/example" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
|
||||
},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
|
||||
t.Fatalf("page-only ci-deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if requests != 2 || writes != 0 {
|
||||
t.Fatalf("page-only preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverUnavailablePreflightStopsBeforeManagedBuild(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
var mu sync.Mutex
|
||||
var requests, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
requests++
|
||||
if r.URL.Path == "/api/v1/repos/owner/example" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
|
||||
},
|
||||
})
|
||||
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
|
||||
t.Fatalf("deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed build ran before successful preflight: %v", err)
|
||||
}
|
||||
if requests != 2 || writes != 0 {
|
||||
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation(t *testing.T) {
|
||||
repository := newDeliveryBuildRepository(t)
|
||||
var mu sync.Mutex
|
||||
var requests, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
requests++
|
||||
if r.URL.Path == "/api/v1/repos/owner/example" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 1, "name": "example", "full_name": "owner/example",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
writeDeliveryProfile(t, repository, cli.Settings{
|
||||
Product: "Example Product",
|
||||
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||
Delivery: &cli.DeliverySettings{
|
||||
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
|
||||
},
|
||||
})
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
|
||||
shimDir := t.TempDir()
|
||||
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
|
||||
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
|
||||
t.Fatalf("ci-deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("dependency preparation ran before successful preflight: %v", err)
|
||||
}
|
||||
if requests != 2 || writes != 0 {
|
||||
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
|
||||
repository := newCLIRepository(t)
|
||||
state := &deliveryServerState{}
|
||||
|
||||
169
delivery_test.go
169
delivery_test.go
@@ -19,6 +19,175 @@ import (
|
||||
releasekit "go.maximumdirect.net/releasekit"
|
||||
)
|
||||
|
||||
func TestDeliveryForgeSelectionIsExplicit(t *testing.T) {
|
||||
var requests int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for _, forge := range []releasekit.Forge{"", " \t", "unknown"} {
|
||||
t.Run(string(forge), func(t *testing.T) {
|
||||
destination := releasekit.DeliveryDestination{
|
||||
Forge: forge, BaseURL: server.URL, Owner: "owner", Repository: "example", Token: "fixture-token",
|
||||
}
|
||||
if err := releasekit.PreflightDelivery(context.Background(), destination); err == nil || !strings.Contains(err.Error(), "forge") {
|
||||
t.Fatalf("PreflightDelivery() error = %v, want explicit forge selection error", err)
|
||||
}
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
Version: "v1.2.3", Destination: destination,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "forge") || result.State != releasekit.DeliveryNotAttempted || result.Version != "v1.2.3" {
|
||||
t.Fatalf("Deliver() = %#v, %v; want selection error before delivery", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want 0 for invalid forge selection", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout(t *testing.T) {
|
||||
var requests, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
t.Errorf("preflight method = %q, want GET", r.Method)
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
requests++
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
|
||||
case "/api/v1/repositories/31":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
|
||||
default:
|
||||
t.Errorf("preflight path = %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := releasekit.PreflightDelivery(context.Background(), facadeDeliveryDestination(server.URL)); err != nil {
|
||||
t.Fatalf("PreflightDelivery() error = %v", err)
|
||||
}
|
||||
if requests != 2 || writes != 0 {
|
||||
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightDeliveryPreservesPublicFailureKindsAndCauses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
status int
|
||||
wantKind releasekit.DeliveryPreflightFailureKind
|
||||
wantReads int
|
||||
canceled bool
|
||||
}{
|
||||
{name: "missing credential", wantKind: releasekit.DeliveryPreflightMissingCredential},
|
||||
{name: "invalid credential", token: "fixture-token", status: http.StatusUnauthorized, wantKind: releasekit.DeliveryPreflightInvalidCredential, wantReads: 1},
|
||||
{name: "inaccessible destination", token: "fixture-token", status: http.StatusForbidden, wantKind: releasekit.DeliveryPreflightInaccessibleDestination, wantReads: 1},
|
||||
{name: "unavailable service", token: "fixture-token", status: http.StatusServiceUnavailable, wantKind: releasekit.DeliveryPreflightUnavailable, wantReads: 1},
|
||||
{name: "canceled request", token: "fixture-token", wantKind: releasekit.DeliveryPreflightUnavailable, canceled: true},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reads := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reads++
|
||||
if test.status != 0 {
|
||||
http.Error(w, "controlled failure", test.status)
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected request to %q", r.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
destination := facadeDeliveryDestination(server.URL)
|
||||
destination.Token = test.token
|
||||
ctx := context.Background()
|
||||
if test.canceled {
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
ctx = canceled
|
||||
}
|
||||
err := releasekit.PreflightDelivery(ctx, destination)
|
||||
var preflight *releasekit.DeliveryPreflightError
|
||||
if !errors.As(err, &preflight) || preflight.Kind != test.wantKind || preflight.Err == nil {
|
||||
t.Fatalf("PreflightDelivery() error = %#v, want kind %q with cause", err, test.wantKind)
|
||||
}
|
||||
if test.canceled && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("PreflightDelivery() error = %v, want cancellation identity", err)
|
||||
}
|
||||
if reads != test.wantReads {
|
||||
t.Fatalf("preflight reads = %d, want %d", reads, test.wantReads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v1.2.3",
|
||||
Spec: releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
OutputDir: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
contents, err := os.ReadFile(prebuilt.Artifacts[0].Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents[0] ^= 0xff
|
||||
if err := os.WriteFile(prebuilt.Artifacts[0].Path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var reads, writes int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
reads++
|
||||
if r.URL.Path == "/api/v1/repos/owner/example" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
|
||||
return
|
||||
}
|
||||
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Prebuilt: &prebuilt,
|
||||
})
|
||||
var preflight *releasekit.DeliveryPreflightError
|
||||
if !errors.As(err, &preflight) || preflight.Kind != releasekit.DeliveryPreflightUnavailable || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want unavailable preflight before tamper verification", result, err)
|
||||
}
|
||||
if reads != 2 || writes != 0 {
|
||||
t.Fatalf("preflight reads=%d writes=%d, want two reads and no writes", reads, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
temporaryRoot := t.TempDir()
|
||||
|
||||
@@ -474,7 +474,7 @@ profile is authoritative; credential verification precedes application downloads
|
||||
|
||||
## Stage 9: Complete regression coverage and dependency checks
|
||||
|
||||
Status: pending.
|
||||
Status: complete.
|
||||
|
||||
1. Finish adapting facade, CLI, HTTP fixture, and benchmark callers. Retain
|
||||
root/nested/major-module identities, page-only delivery, managed/prebuilt
|
||||
|
||||
@@ -226,6 +226,33 @@ func checkImportDirections(packages []listedPackage) error {
|
||||
}
|
||||
visit(modulePath + "/buildinfo")
|
||||
|
||||
for _, root := range []string{
|
||||
modulePath + "/internal/delivery",
|
||||
modulePath + "/internal/forge",
|
||||
modulePath + "/internal/artifact",
|
||||
} {
|
||||
seen := make(map[string]bool)
|
||||
var visitDelivery func(string)
|
||||
visitDelivery = func(path string) {
|
||||
if seen[path] {
|
||||
return
|
||||
}
|
||||
seen[path] = true
|
||||
if path == modulePath+"/internal/gitea" {
|
||||
violations = append(violations, fmt.Sprintf("shared package %s depends on Gitea adapter", root))
|
||||
return
|
||||
}
|
||||
pkg, ok := byPath[path]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, imported := range pkg.Imports {
|
||||
visitDelivery(imported)
|
||||
}
|
||||
}
|
||||
visitDelivery(root)
|
||||
}
|
||||
|
||||
if len(violations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -162,6 +162,30 @@ func TestRuntimeFacadeCannotDependOnExternalPackages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDeliveryPackagesCannotDependOnGitea(t *testing.T) {
|
||||
allowed := []listedPackage{
|
||||
{ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/forge", modulePath + "/internal/artifact"}},
|
||||
{ImportPath: modulePath + "/internal/forge"},
|
||||
{ImportPath: modulePath + "/internal/artifact"},
|
||||
{ImportPath: modulePath + "/internal/gitea"},
|
||||
}
|
||||
if err := checkImportDirections(allowed); err != nil {
|
||||
t.Fatalf("shared delivery boundary rejected neutral dependencies: %v", err)
|
||||
}
|
||||
|
||||
violating := []listedPackage{
|
||||
{ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/policy"}},
|
||||
{ImportPath: modulePath + "/internal/policy", Imports: []string{modulePath + "/internal/gitea"}},
|
||||
{ImportPath: modulePath + "/internal/forge"},
|
||||
{ImportPath: modulePath + "/internal/artifact"},
|
||||
{ImportPath: modulePath + "/internal/gitea"},
|
||||
}
|
||||
err := checkImportDirections(violating)
|
||||
if err == nil || !strings.Contains(err.Error(), "shared package "+modulePath+"/internal/delivery depends on Gitea adapter") {
|
||||
t.Fatalf("checkImportDirections() error = %v, want transitive shared-delivery violation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func checkFixture(t *testing.T, facadeSource string) []string {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
|
||||
@@ -2,25 +2,12 @@ package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
type AssetState string
|
||||
|
||||
const (
|
||||
AssetMatching AssetState = "matching"
|
||||
AssetMissing AssetState = "missing"
|
||||
AssetConflicting AssetState = "conflicting"
|
||||
AssetUnverifiable AssetState = "unverifiable"
|
||||
)
|
||||
|
||||
type RemoteAsset struct {
|
||||
@@ -30,18 +17,6 @@ type RemoteAsset struct {
|
||||
DownloadURL string
|
||||
}
|
||||
|
||||
type AssetInspection struct {
|
||||
Expected artifact.ExpectedAsset
|
||||
State AssetState
|
||||
Existing []RemoteAsset
|
||||
Err error
|
||||
}
|
||||
|
||||
type Inspection struct {
|
||||
Assets []AssetInspection
|
||||
Unrelated []RemoteAsset
|
||||
}
|
||||
|
||||
type assetResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -49,66 +24,6 @@ type assetResponse struct {
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// InspectAssets lists the release's attachments and compares every declared
|
||||
// asset by streamed SHA-256 content. It does not create or modify remote data.
|
||||
func (c Client) InspectAssets(ctx context.Context, destination Destination, releaseID int64, expected []artifact.ExpectedAsset) (Inspection, error) {
|
||||
if releaseID <= 0 {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(destination.Token) == "" {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
base, err := parseBaseURL(destination.BaseURL)
|
||||
if err != nil {
|
||||
return Inspection{}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
||||
}
|
||||
if err := artifact.ValidateExpectedAssets(expected); err != nil {
|
||||
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)
|
||||
listed, err := c.listAssets(ctx, c.httpClient(), destination.Token, releaseID, apiRoot)
|
||||
if err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
remoteByName := make(map[string][]RemoteAsset, len(listed))
|
||||
for _, item := range listed {
|
||||
remoteByName[item.Filename] = append(remoteByName[item.Filename], item)
|
||||
}
|
||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
||||
declared := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
declared[item.Filename] = struct{}{}
|
||||
matches := remoteByName[item.Filename]
|
||||
observation := AssetInspection{Expected: item, Existing: matches}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
observation.State = AssetMissing
|
||||
case 1:
|
||||
remoteHash, _, err := c.downloadHash(ctx, c.httpClient(), base, destination.Token, matches[0].DownloadURL)
|
||||
if err != nil {
|
||||
observation.State = AssetUnverifiable
|
||||
observation.Err = err
|
||||
} else if remoteHash == item.SHA256 {
|
||||
observation.State = AssetMatching
|
||||
} else {
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
default:
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
inspection.Assets = append(inspection.Assets, observation)
|
||||
}
|
||||
for _, item := range listed {
|
||||
if _, expected := declared[item.Filename]; !expected {
|
||||
inspection.Unrelated = append(inspection.Unrelated, item)
|
||||
}
|
||||
}
|
||||
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"))
|
||||
@@ -131,20 +46,6 @@ func (c Client) listAssets(ctx context.Context, client *http.Client, token strin
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
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) == "" {
|
||||
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
||||
|
||||
@@ -2,310 +2,115 @@ package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
const assetListPath = "/api/v1/repos/owner/project/releases/10/assets"
|
||||
|
||||
func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) {
|
||||
content := []byte("selected binary")
|
||||
func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" {
|
||||
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
_, _ = io.WriteString(w, "null")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed {
|
||||
t.Fatalf("ListAssets() error = %v, want malformed attachment response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendOpenAssetFollowsSameOriginRedirectWithAuthorization(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 at %s = %q, want %q", r.URL.Path, got, want)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case assetListPath:
|
||||
writeAssetList(t, w, []assetResponse{
|
||||
{ID: 4, Name: "app-v1.2.3-linux-amd64", Size: int64(len(content)), BrowserDownloadURL: "/files/app"},
|
||||
{ID: 8, Name: "notes.txt", Size: 9, BrowserDownloadURL: "/files/notes"},
|
||||
})
|
||||
case "/files/app":
|
||||
_, _ = w.Write(content)
|
||||
case "/files/notes":
|
||||
_, _ = w.Write([]byte("unrelated"))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{assetFor("app-v1.2.3-linux-amd64", content)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inspection.Assets) != 1 || inspection.Assets[0].State != AssetMatching {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
if len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "notes.txt" {
|
||||
t.Fatalf("unrelated = %#v", inspection.Unrelated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) {
|
||||
expected := []artifact.ExpectedAsset{assetFor("app.bin", []byte("alpha")), assetFor("other.bin", []byte("beta"))}
|
||||
var appDownloads, otherDownloads atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{
|
||||
{ID: 2, Name: expected[0].Filename, Size: 5, BrowserDownloadURL: "/files/app"},
|
||||
{ID: 3, Name: expected[1].Filename, Size: 4, BrowserDownloadURL: "/files/other"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/files/app" {
|
||||
appDownloads.Add(1)
|
||||
_, _ = w.Write([]byte("bravo"))
|
||||
return
|
||||
}
|
||||
otherDownloads.Add(1)
|
||||
_, _ = w.Write([]byte("beta"))
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetConflicting || inspection.Assets[1].State != AssetMatching || appDownloads.Load() != 1 || otherDownloads.Load() != 1 {
|
||||
t.Fatalf("inspection = %#v, downloads = %d and %d", inspection, appDownloads.Load(), otherDownloads.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
|
||||
binaries := []artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
|
||||
manifest, err := artifact.BuildManifest(binaries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 3, Name: manifest.Filename, Size: manifest.Size, BrowserDownloadURL: "/files/manifest"}})
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/files/manifest" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(manifest.Content)
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, append(binaries, manifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inspection.Assets) != 3 || inspection.Assets[0].State != AssetMissing || inspection.Assets[1].State != AssetMissing || inspection.Assets[2].State != AssetMatching {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsRejectsSubsetManifest(t *testing.T) {
|
||||
binaries := []artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
|
||||
full, err := artifact.BuildManifest(binaries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subset, err := artifact.BuildManifest(binaries[:1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 3, Name: artifact.ManifestFilename, Size: subset.Size, BrowserDownloadURL: "/files/manifest"}})
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(subset.Content)
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{full})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetConflicting {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsRejectsDifferentManifest(t *testing.T) {
|
||||
expected, err := artifact.BuildManifest([]artifact.ExpectedAsset{assetFor("alpha.bin", []byte("alpha"))})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
different := []byte("not the declared checksum list\n")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 3, Name: artifact.ManifestFilename, Size: int64(len(different)), BrowserDownloadURL: "/files/manifest"}})
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(different)
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{expected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetConflicting {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsMarksUnreadableContentUnverifiable(t *testing.T) {
|
||||
expected := assetFor("app.bin", []byte("alpha"))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 2, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/app"}})
|
||||
return
|
||||
}
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{expected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetUnverifiable || inspection.Assets[0].Err == nil {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, assetListPath)
|
||||
_, _ = w.Write([]byte("null"))
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{assetFor("app.bin", []byte("alpha"))})
|
||||
if failureKind(t, err) != FailureMalformed {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsSourceOnlyDoesNotDeclareManifest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, assetListPath)
|
||||
writeAssetList(t, w, []assetResponse{{ID: 7, Name: "historical.txt", Size: 3}})
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inspection.Assets) != 0 || len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "historical.txt" {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
|
||||
content := []byte("alpha")
|
||||
expected := assetFor("app.bin", content)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/redirect"}})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/files/redirect" {
|
||||
case "/files/redirect":
|
||||
http.Redirect(w, r, "/files/content", http.StatusFound)
|
||||
return
|
||||
case "/files/content":
|
||||
_, _ = io.WriteString(w, "streamed asset")
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
if r.URL.Path == "/files/content" {
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
return
|
||||
}))
|
||||
defer server.Close()
|
||||
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []artifact.ExpectedAsset{expected})
|
||||
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: "/files/redirect"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inspection.Assets[0].State != AssetMatching {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
content, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(content) != "streamed asset" {
|
||||
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
|
||||
for _, mode := range []string{"direct", "redirect", "conflicting"} {
|
||||
func TestBackendOpenAssetDoesNotForwardAuthorizationAcrossOrigin(t *testing.T) {
|
||||
for _, mode := range []string{"direct", "redirect"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
expected := assetFor("app.bin", []byte("matching bytes"))
|
||||
var externalGets atomic.Int64
|
||||
var externalRequests atomic.Int32
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
externalGets.Add(1)
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Error("Gitea token reached external host")
|
||||
externalRequests.Add(1)
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("external authorization = %q, want empty", got)
|
||||
}
|
||||
content := []byte("matching bytes")
|
||||
if mode == "conflicting" {
|
||||
content = []byte("different bytes")
|
||||
}
|
||||
w.Write(content)
|
||||
_, _ = io.WriteString(w, "external asset")
|
||||
}))
|
||||
defer external.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAssetGET(t, r, "")
|
||||
if r.URL.Path == assetListPath {
|
||||
download := external.URL + "/content"
|
||||
if mode == "redirect" {
|
||||
download = "/redirect"
|
||||
}
|
||||
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}})
|
||||
return
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("origin authorization = %q, want %q", got, want)
|
||||
}
|
||||
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []artifact.ExpectedAsset{expected})
|
||||
|
||||
downloadURL := external.URL + "/content"
|
||||
if mode == "redirect" {
|
||||
downloadURL = "/redirect"
|
||||
}
|
||||
backend := NewBackend(testDestination(origin.URL), origin.Client())
|
||||
stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: downloadURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := AssetMatching
|
||||
if mode == "conflicting" {
|
||||
want = AssetConflicting
|
||||
}
|
||||
if inspection.Assets[0].State != want || externalGets.Load() != 1 {
|
||||
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load())
|
||||
content, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil || closeErr != nil || string(content) != "external asset" || externalRequests.Load() != 1 {
|
||||
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v, external requests = %d", content, readErr, closeErr, externalRequests.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assetFor(filename string, content []byte) artifact.ExpectedAsset {
|
||||
sum := sha256.Sum256(content)
|
||||
return artifact.ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)}
|
||||
}
|
||||
func TestBackendListAssetsDecodesAttachmentMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
func writeAssetList(t *testing.T, w http.ResponseWriter, assets []assetResponse) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(assets); err != nil {
|
||||
t.Error(err)
|
||||
assets, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAssetGET(t *testing.T, r *http.Request, expectedPath string) {
|
||||
t.Helper()
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", r.Method)
|
||||
}
|
||||
if expectedPath != "" && r.URL.Path != expectedPath {
|
||||
t.Errorf("path = %q, want %q", r.URL.Path, expectedPath)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "token "+testToken {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testDestination(base string) Destination {
|
||||
return Destination{BaseURL: base, Owner: "owner", Repository: "project", Token: testToken}
|
||||
}
|
||||
|
||||
@@ -400,10 +400,56 @@ func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetWaitsForMultipartWriterAfterReaderFailure(t *testing.T) {
|
||||
readerErr := errors.New("asset reader failed")
|
||||
readDone := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/project/releases/tags/v1.2.3" {
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3"})
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin", Size: 0})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
||||
if err != nil {
|
||||
t.Fatalf("FindRelease() error = %v", err)
|
||||
}
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), release.Handle, "app.bin", failingUploadReader{err: readerErr, done: readDone})
|
||||
select {
|
||||
case <-readDone:
|
||||
default:
|
||||
t.Fatal("UploadAsset() returned before the multipart worker finished reading")
|
||||
}
|
||||
if !errors.Is(err, readerErr) {
|
||||
t.Fatalf("UploadAsset() error = %v, want reader cause %v", err, readerErr)
|
||||
}
|
||||
if !evidence.Attempted || evidence.Disposition != forge.WriteUncertain {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, want uncertain attempted write", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
type failingUploadReader struct {
|
||||
err error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (reader failingUploadReader) Read([]byte) (int, error) {
|
||||
close(reader.done)
|
||||
return 0, reader.err
|
||||
}
|
||||
|
||||
func validCreateReleaseRequest() forge.CreateReleaseRequest {
|
||||
return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"}
|
||||
}
|
||||
|
||||
func testDestination(baseURL string) Destination {
|
||||
return Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: "fixture-secret-token"}
|
||||
}
|
||||
|
||||
type trackedReader struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FailureKind string
|
||||
@@ -55,14 +54,7 @@ type Destination struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Destination Destination
|
||||
Tag string
|
||||
Commit string
|
||||
Note string
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
type release struct {
|
||||
ID int64
|
||||
URL string
|
||||
TagName string
|
||||
@@ -72,12 +64,6 @@ type Release struct {
|
||||
Prerelease bool
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Release Release
|
||||
Created bool
|
||||
WriteAttempted bool // a create request was issued, including reconciled creates.
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
@@ -108,109 +94,29 @@ type createReleaseRequest struct {
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
// EnsureRelease verifies the hosted tag and finds or creates its stable release
|
||||
// page. It never edits a release that already exists.
|
||||
func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, error) {
|
||||
if err := validateRequest(request); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
base, err := parseBaseURL(request.Destination.BaseURL)
|
||||
if err != nil {
|
||||
return Result{}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
client := c.httpClient()
|
||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
|
||||
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag)
|
||||
|
||||
var tag tagResponse
|
||||
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if tag.Commit.SHA != request.Commit {
|
||||
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit))
|
||||
}
|
||||
|
||||
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag)
|
||||
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||
if err == nil {
|
||||
return acceptExisting(existing, request)
|
||||
}
|
||||
if status != http.StatusNotFound {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
title := noteTitle(request.Note)
|
||||
body, err := json.Marshal(createReleaseRequest{
|
||||
TagName: request.Tag, TargetCommitish: request.Commit,
|
||||
Name: title, Body: request.Note,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, failure(FailureInvalid, "encode release", 0, err)
|
||||
}
|
||||
created, postStatus, postErr := c.postRelease(ctx, client, request.Destination.Token, apiRoot+"/releases", body)
|
||||
if postErr == nil {
|
||||
result, verifyErr := acceptCreated(created, request)
|
||||
if verifyErr == nil {
|
||||
result.WriteAttempted = true
|
||||
return result, nil
|
||||
}
|
||||
postErr = verifyErr
|
||||
}
|
||||
if !shouldReconcileWrite(postStatus, postErr) {
|
||||
return Result{}, postErr
|
||||
}
|
||||
|
||||
// A conflict or interrupted server response may have followed a successful
|
||||
// create. One lookup resolves only a page with the exact selected content.
|
||||
retry, retryStatus, retryErr := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||
if retryErr == nil {
|
||||
result, matchErr := acceptExisting(retry, request)
|
||||
if matchErr == nil {
|
||||
result.WriteAttempted = true
|
||||
return result, nil
|
||||
}
|
||||
return Result{}, failure(FailureConflict, "resolve release creation", postStatus, matchErr)
|
||||
}
|
||||
if retryStatus == http.StatusNotFound {
|
||||
if postStatus == http.StatusConflict {
|
||||
return Result{}, failure(FailureConflict, "create release", postStatus, postErr)
|
||||
}
|
||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, postErr)
|
||||
}
|
||||
return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr))
|
||||
}
|
||||
|
||||
func shouldReconcileWrite(status int, err error) bool {
|
||||
if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var failure *Failure
|
||||
return errors.As(err, &failure) && (failure.Kind == FailureTransport || failure.Kind == FailureMalformed)
|
||||
}
|
||||
|
||||
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (Release, int, error) {
|
||||
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (release, int, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
||||
return release{}, 0, failure(FailureInvalid, "find release", 0, err)
|
||||
}
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureTransport, "find release", 0, err)
|
||||
return release{}, 0, failure(FailureTransport, "find release", 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return Release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
||||
return release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return Release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
||||
return release{}, response.StatusCode, responseFailure("find release", response.StatusCode)
|
||||
}
|
||||
var decoded releaseResponse
|
||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||
return Release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
||||
return release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err)
|
||||
}
|
||||
return decoded.Release(), response.StatusCode, nil
|
||||
return decoded.release(), response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c Client) getJSON(ctx context.Context, client *http.Client, token, operation, endpoint string, destination any) error {
|
||||
@@ -234,27 +140,27 @@ func (c Client) getJSON(ctx context.Context, client *http.Client, token, operati
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (Release, int, error) {
|
||||
func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (release, int, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
||||
return release{}, 0, failure(FailureInvalid, "create release", 0, err)
|
||||
}
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Release{}, 0, failure(FailureTransport, "create release", 0, err)
|
||||
return release{}, 0, failure(FailureTransport, "create release", 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return Release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
||||
return release{}, response.StatusCode, responseFailure("create release", response.StatusCode)
|
||||
}
|
||||
var decoded releaseResponse
|
||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
||||
return Release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
||||
return release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err)
|
||||
}
|
||||
return decoded.Release(), response.StatusCode, nil
|
||||
return decoded.release(), response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c Client) httpClient() *http.Client {
|
||||
@@ -269,28 +175,6 @@ func (c Client) httpClient() *http.Client {
|
||||
return &clientCopy
|
||||
}
|
||||
|
||||
func validateRequest(request Request) error {
|
||||
if strings.TrimSpace(request.Tag) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
|
||||
}
|
||||
if !validObjectID(request.Commit) {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Owner) == "" || strings.ContainsAny(request.Destination.Owner, "/\\") {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea owner is required as one path segment"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Repository) == "" || strings.ContainsAny(request.Destination.Repository, "/\\") {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea repository is required as one path segment"))
|
||||
}
|
||||
if strings.TrimSpace(request.Destination.Token) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
if noteTitle(request.Note) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("release note must begin with a level-one heading"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
@@ -302,30 +186,8 @@ func parseBaseURL(raw string) (*url.URL, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func acceptExisting(release Release, request Request) (Result, error) {
|
||||
if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease {
|
||||
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
|
||||
}
|
||||
return Result{Release: release}, nil
|
||||
}
|
||||
|
||||
func acceptCreated(release Release, request Request) (Result, error) {
|
||||
if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
||||
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
|
||||
}
|
||||
return Result{Release: release, Created: true}, nil
|
||||
}
|
||||
|
||||
func (response releaseResponse) Release() Release {
|
||||
return Release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
||||
}
|
||||
|
||||
func noteTitle(note string) string {
|
||||
first := strings.SplitN(note, "\n", 2)[0]
|
||||
if !strings.HasPrefix(first, "# ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
||||
func (response releaseResponse) release() release {
|
||||
return release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease}
|
||||
}
|
||||
|
||||
func validObjectID(value string) bool {
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testCommit = "0123456789012345678901234567890123456789"
|
||||
const testNote = "# Example v1.2.3\n\nRelease details.\n"
|
||||
const testToken = "fixture-secret-token"
|
||||
|
||||
func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) {
|
||||
server, _ := newReleaseServer(t, releaseFixture{title: "Historical title"}, false)
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Created || result.Release.Title != "Historical title" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) {
|
||||
tag := "integrations/ckconfig/v1.2.3"
|
||||
state := &fixtureState{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
state.posts.Add(1)
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
|
||||
t.Errorf("create payload = %#v", payload)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
request := testRequest(server.URL)
|
||||
request.Tag = tag
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), request)
|
||||
if err != nil || !result.Created || state.posts.Load() != 1 {
|
||||
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
|
||||
server, _ := newReleaseServer(t, releaseFixture{}, true)
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Created || result.Release.Title != "Example v1.2.3" || result.Release.Body != testNote {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) {
|
||||
server, state := newReleaseServer(t, releaseFixture{title: "Keep this title", body: "Different body"}, false)
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureConflict {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if got := state.posts.Load(); got != 0 {
|
||||
t.Fatalf("release creates = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureUnauthorized {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if fmt.Sprint(err) == "" || contains(fmt.Sprint(err), testToken) {
|
||||
t.Fatalf("error exposes unexpected content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) {
|
||||
var posts atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
posts.Add(1)
|
||||
}
|
||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||
_, _ = w.Write([]byte("{"))
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureMalformed || posts.Load() != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
|
||||
var posts atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||
if r.Method == http.MethodPost {
|
||||
posts.Add(1)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureConflict || posts.Load() != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
|
||||
var posts atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
|
||||
if r.Method == http.MethodPost {
|
||||
posts.Add(1)
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureNotFound || posts.Load() != 0 {
|
||||
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called.Store(true) }))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL))
|
||||
if !errors.Is(err, context.Canceled) || called.Load() {
|
||||
t.Fatalf("error = %v, server called = %t", err, called.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
|
||||
var created atomic.Bool
|
||||
var lookups atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
lookups.Add(1)
|
||||
if !created.Load() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: "Example v1.2.3"})
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||
t.Errorf("create payload = %#v", payload)
|
||||
return
|
||||
}
|
||||
created.Store(true)
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Error("HTTP writer does not support hijacking")
|
||||
return
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_ = connection.Close()
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Created || lookups.Load() != 2 {
|
||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
||||
var lookups atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
lookups.Add(1)
|
||||
if lookups.Load() == 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: "Created by another request"})
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Created || result.Release.Title != "Created by another request" || lookups.Load() != 2 {
|
||||
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
|
||||
var lookups atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
lookups.Add(1)
|
||||
if lookups.Load() == 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if failureKind(t, err) != FailureUnknown || lookups.Load() != 2 {
|
||||
t.Fatalf("error = %v, release lookups = %d", err, lookups.Load())
|
||||
}
|
||||
}
|
||||
|
||||
type releaseFixture struct {
|
||||
title string
|
||||
body string
|
||||
draft bool
|
||||
pre bool
|
||||
}
|
||||
|
||||
type fixtureState struct{ posts atomic.Int64 }
|
||||
|
||||
func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) {
|
||||
t.Helper()
|
||||
state := &fixtureState{}
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
if existing.title == "" && existing.body == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, existing)
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
state.posts.Add(1)
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
||||
t.Errorf("create payload = %#v", payload)
|
||||
return
|
||||
}
|
||||
if !createMissing {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: payload.Name})
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
return
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
return server, state
|
||||
}
|
||||
|
||||
func writeTag(t *testing.T, w http.ResponseWriter, commit string) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit)
|
||||
}
|
||||
|
||||
func writeRelease(t *testing.T, w http.ResponseWriter, fixture releaseFixture) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, HTMLURL: "https://gitea.example/releases/10", TagName: "v1.2.3", Name: fixture.title, Body: fixture.bodyOrDefault(), Draft: fixture.draft, Prerelease: fixture.pre})
|
||||
}
|
||||
|
||||
func (fixture releaseFixture) bodyOrDefault() string {
|
||||
if fixture.body == "" {
|
||||
return testNote
|
||||
}
|
||||
return fixture.body
|
||||
}
|
||||
|
||||
func assertRequest(t *testing.T, request *http.Request, method, path string) {
|
||||
t.Helper()
|
||||
if request.Method != method || request.URL.Path != path {
|
||||
t.Errorf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
|
||||
return
|
||||
}
|
||||
assertAuth(t, request)
|
||||
}
|
||||
|
||||
func assertAuth(t *testing.T, request *http.Request) {
|
||||
t.Helper()
|
||||
if got := request.Header.Get("Authorization"); got != "token "+testToken {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func testClient(server *httptest.Server) Client {
|
||||
return Client{HTTPClient: server.Client()}
|
||||
}
|
||||
|
||||
func testRequest(baseURL string) Request {
|
||||
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote}
|
||||
}
|
||||
|
||||
func failureKind(t *testing.T, err error) FailureKind {
|
||||
t.Helper()
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) {
|
||||
t.Fatalf("error type = %T (%v), want *Failure", err, err)
|
||||
}
|
||||
return failure.Kind
|
||||
}
|
||||
|
||||
func contains(value, substring string) bool {
|
||||
return len(substring) > 0 && len(value) >= len(substring) && strings.Contains(value, substring)
|
||||
}
|
||||
|
||||
func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
|
||||
for _, mode := range []string{"matching", "conflicting", "unavailable"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
var posts, lookups atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"):
|
||||
writeTag(t, w, testCommit)
|
||||
case strings.Contains(r.URL.Path, "/releases/tags/"):
|
||||
lookups.Add(1)
|
||||
if posts.Load() == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if mode == "unavailable" {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
body := testNote
|
||||
if mode == "conflicting" {
|
||||
body = "other content"
|
||||
}
|
||||
writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body})
|
||||
case r.Method == http.MethodPost:
|
||||
posts.Add(1)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote})
|
||||
default:
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
if posts.Load() != 1 || lookups.Load() != 2 {
|
||||
t.Fatalf("posts=%d lookups=%d", posts.Load(), lookups.Load())
|
||||
}
|
||||
switch mode {
|
||||
case "matching":
|
||||
if err != nil || !result.WriteAttempted {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
case "conflicting":
|
||||
if failureKind(t, err) != FailureConflict {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "unavailable":
|
||||
if failureKind(t, err) != FailureUnknown {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,208 +1,15 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
type DeliveryState string
|
||||
|
||||
const (
|
||||
DeliveryNotAttempted DeliveryState = "not_attempted"
|
||||
DeliveryComplete DeliveryState = "complete"
|
||||
DeliveryPartial DeliveryState = "partial"
|
||||
DeliveryConflict DeliveryState = "conflict"
|
||||
DeliveryUnknown DeliveryState = "unknown"
|
||||
)
|
||||
|
||||
type AssetIdentity struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// UploadResult reports the current asset observations from one upload attempt.
|
||||
// Each identity appears in at most one category; early failures can leave some
|
||||
// declared assets unreported.
|
||||
type UploadResult struct {
|
||||
State DeliveryState
|
||||
// Matching contains existing attachments with the declared content.
|
||||
Matching []AssetIdentity
|
||||
// Uploaded contains assets whose upload was confirmed during this invocation.
|
||||
Uploaded []AssetIdentity
|
||||
// Pending contains assets known to be missing.
|
||||
Pending []AssetIdentity
|
||||
// Conflicting contains assets with detected content conflicts.
|
||||
Conflicting []AssetIdentity
|
||||
// Uncertain contains assets whose remote state could not be resolved.
|
||||
Uncertain []AssetIdentity
|
||||
}
|
||||
|
||||
// UploadMissing uploads assets classified missing by InspectAssets. It stops
|
||||
// before the first write if any declared existing asset conflicts or cannot be
|
||||
// verified, and never replaces an existing attachment.
|
||||
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||
return c.uploadMissing(ctx, destination, releaseID, inspection, true)
|
||||
}
|
||||
|
||||
// UploadVerifiedMissing uploads missing assets whose local contents have already
|
||||
// passed VerifyLocalAssets. It reuses that verification while retaining remote
|
||||
// inspection, conflict handling, and upload reconciliation.
|
||||
func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
|
||||
return c.uploadMissing(ctx, destination, releaseID, inspection, false)
|
||||
}
|
||||
|
||||
func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) {
|
||||
if releaseID <= 0 {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(destination.Token) == "" {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
base, err := parseBaseURL(destination.BaseURL)
|
||||
if err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
||||
}
|
||||
expected := orderedExpected(inspection.Assets)
|
||||
if err := artifact.ValidateExpectedAssets(expected); err != nil {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "preflight release assets", 0, err)
|
||||
}
|
||||
result := UploadResult{State: DeliveryPartial}
|
||||
preflightConflict := false
|
||||
preflightUnknown := false
|
||||
for _, observation := range inspection.Assets {
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(observation.Expected))
|
||||
case AssetMissing:
|
||||
result.Pending = append(result.Pending, identity(observation.Expected))
|
||||
case AssetConflicting:
|
||||
preflightConflict = true
|
||||
result.Conflicting = append(result.Conflicting, identity(observation.Expected))
|
||||
case AssetUnverifiable:
|
||||
preflightUnknown = true
|
||||
result.Uncertain = append(result.Uncertain, identity(observation.Expected))
|
||||
default:
|
||||
return result, failure(FailureInvalid, "preflight release assets", 0, fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename))
|
||||
}
|
||||
}
|
||||
if preflightConflict {
|
||||
result.State = DeliveryConflict
|
||||
return result, failure(FailureConflict, "preflight release assets", 0, errors.New("one or more existing attachments conflict"))
|
||||
}
|
||||
if preflightUnknown {
|
||||
result.State = DeliveryNotAttempted
|
||||
return result, failure(FailureUnverifiable, "preflight release assets", 0, errors.New("one or more existing attachments could not be verified"))
|
||||
}
|
||||
if len(expected) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
return result, nil
|
||||
}
|
||||
if len(result.Pending) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
return result, nil
|
||||
}
|
||||
|
||||
missing := make(map[string]artifact.ExpectedAsset, len(result.Pending))
|
||||
for _, observation := range inspection.Assets {
|
||||
if observation.State == AssetMissing {
|
||||
missing[observation.Expected.Filename] = observation.Expected
|
||||
}
|
||||
}
|
||||
if verifyLocal {
|
||||
missingAssets := make([]artifact.ExpectedAsset, 0, len(result.Pending))
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
missingAssets = append(missingAssets, item)
|
||||
}
|
||||
if err := artifact.VerifyLocalAssets(missingAssets); err != nil {
|
||||
kind := FailureInvalid
|
||||
if errors.Is(err, artifact.ErrAssetConflict) {
|
||||
kind = FailureConflict
|
||||
}
|
||||
return result, failure(kind, "verify local assets", 0, err)
|
||||
}
|
||||
}
|
||||
|
||||
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||
client := c.httpClient()
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
content, openErr := assetContent(item)
|
||||
if openErr != nil {
|
||||
return result, failure(FailureInvalid, "open local release asset", 0, openErr)
|
||||
}
|
||||
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item.Filename, content)
|
||||
_ = content.Close()
|
||||
if uploadErr == nil {
|
||||
result.Uploaded = append(result.Uploaded, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
continue
|
||||
}
|
||||
if !shouldReconcileWrite(status, uploadErr) {
|
||||
return result, uploadErr
|
||||
}
|
||||
inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []artifact.ExpectedAsset{item})
|
||||
if inspectErr != nil || len(inspection.Assets) != 1 {
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, inspectErr))
|
||||
}
|
||||
observation := inspection.Assets[0]
|
||||
switch observation.State {
|
||||
case AssetMatching:
|
||||
result.Matching = append(result.Matching, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
case AssetConflicting:
|
||||
result.State = DeliveryConflict
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||
case AssetMissing:
|
||||
if status == http.StatusConflict {
|
||||
result.State = DeliveryConflict
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Conflicting = append(result.Conflicting, identity(item))
|
||||
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
|
||||
}
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, uploadErr)
|
||||
default:
|
||||
result.State = DeliveryUnknown
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
result.Uncertain = append(result.Uncertain, identity(item))
|
||||
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, observation.Err))
|
||||
}
|
||||
}
|
||||
if len(result.Pending) == 0 {
|
||||
result.State = DeliveryComplete
|
||||
} else {
|
||||
result.State = DeliveryPartial
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, filename string, content io.Reader) (int, error) {
|
||||
if content == nil {
|
||||
return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required"))
|
||||
@@ -263,40 +70,3 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
}
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func assetContent(asset artifact.ExpectedAsset) (io.ReadCloser, error) {
|
||||
if len(asset.Content) > 0 {
|
||||
return io.NopCloser(bytes.NewReader(asset.Content)), nil
|
||||
}
|
||||
return os.Open(asset.Path)
|
||||
}
|
||||
|
||||
func orderedExpected(observations []AssetInspection) []artifact.ExpectedAsset {
|
||||
result := make([]artifact.ExpectedAsset, 0, len(observations))
|
||||
for _, observation := range observations {
|
||||
result = append(result, observation.Expected)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Filename == artifact.ManifestFilename {
|
||||
return false
|
||||
}
|
||||
if result[j].Filename == artifact.ManifestFilename {
|
||||
return true
|
||||
}
|
||||
return result[i].Filename < result[j].Filename
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func identity(asset artifact.ExpectedAsset) AssetIdentity {
|
||||
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
|
||||
}
|
||||
|
||||
func removeIdentity(identities []AssetIdentity, filename string) []AssetIdentity {
|
||||
for index, item := range identities {
|
||||
if item.Filename == filename {
|
||||
return append(identities[:index], identities[index+1:]...)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||
)
|
||||
|
||||
func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte)}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "beta.bin"})
|
||||
inspection, err := client.InspectAssets(t.Context(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := readExpectedFile(t, assets[1])
|
||||
contents[0] ^= 0xff
|
||||
if err := os.WriteFile(assets[1].Path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(t.Context(), destination, 10, inspection)
|
||||
if err == nil || result.State != DeliveryPartial {
|
||||
t.Fatalf("upload = %#v, error = %v; want local verification failure", result, err)
|
||||
}
|
||||
if posts := fixture.postNames(); len(posts) != 0 {
|
||||
t.Fatalf("uploaded %v before rejecting local content", posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"})
|
||||
manifest, err := artifact.BuildManifest(assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets = append(assets, manifest)
|
||||
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || first.State != DeliveryPartial {
|
||||
t.Fatalf("first upload result = %#v, error = %v", first, err)
|
||||
}
|
||||
if got, want := names(first.Uploaded), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uploaded = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(first.Pending), []string{"zeta.bin", "beta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, first)
|
||||
|
||||
secondInspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := client.UploadMissing(context.Background(), destination, 10, secondInspection)
|
||||
if err != nil || second.State != DeliveryComplete {
|
||||
t.Fatalf("second upload result = %#v, error = %v", second, err)
|
||||
}
|
||||
if got, want := names(second.Uploaded), []string{"beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("second uploaded = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := fixture.postNames(), []string{"alpha.bin", "beta.bin", "beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("POST order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), conflictName: "app.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err != nil || result.State != DeliveryComplete {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Matching), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("matching = %v, want %v", got, want)
|
||||
}
|
||||
if len(result.Uploaded) != 0 || len(fixture.postNames()) != 1 {
|
||||
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
|
||||
assets := localAssets(t, []string{"app.bin"})
|
||||
manifest, err := artifact.BuildManifest(assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: artifact.ManifestFilename}
|
||||
// The local binary is represented by its file path; the server starts with
|
||||
// that exact content so only the checksum manifest needs uploading.
|
||||
fixture.files["app.bin"] = readExpectedFile(t, assets[0])
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
expected := append(assets, manifest)
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err != nil || result.State != DeliveryComplete {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Matching), []string{"app.bin", artifact.ManifestFilename}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("matching = %v, want %v", got, want)
|
||||
}
|
||||
if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{artifact.ManifestFilename}) {
|
||||
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: "app.bin", failListAfter: 1}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin", "later.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryUnknown {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"later.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Uncertain), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uncertain = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
if got := fixture.postNames(); !reflect.DeepEqual(got, []string{"app.bin"}) {
|
||||
t.Fatalf("POST names = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingPreflightConflictPreventsEveryWrite(t *testing.T) {
|
||||
fixture := &uploadFixture{files: map[string][]byte{"app.bin": []byte("different bytes")}}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"app.bin", "other.bin"})
|
||||
inspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, inspection)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if len(fixture.postNames()) != 0 {
|
||||
t.Fatalf("uploads happened before conflict review: %v", fixture.postNames())
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"other.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingKeepsLaterPendingAfterAmbiguousMissingUpload(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), dropAckMissingName: "alpha.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryUnknown {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Uncertain), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uncertain = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
if got, want := fixture.postNames(), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("POST names = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMissingMovesReconciledConflictOutOfPending(t *testing.T) {
|
||||
fixture := &uploadFixture{
|
||||
files: make(map[string][]byte),
|
||||
conflictName: "alpha.bin",
|
||||
conflictDifferentContent: true,
|
||||
}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
func TestUploadMissingTreatsUnresolvedNameConflictAsConflict(t *testing.T) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), conflictMissingName: "alpha.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
client := testClient(server)
|
||||
destination := testDestination(server.URL)
|
||||
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
|
||||
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
|
||||
if err == nil || result.State != DeliveryConflict {
|
||||
t.Fatalf("result = %#v, error = %v", result, err)
|
||||
}
|
||||
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("conflicting = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pending = %v, want %v", got, want)
|
||||
}
|
||||
assertAssetCategoriesDisjoint(t, result)
|
||||
}
|
||||
|
||||
type uploadFixture struct {
|
||||
mu sync.Mutex
|
||||
files map[string][]byte
|
||||
posts []string
|
||||
failName string
|
||||
conflictName string
|
||||
conflictDifferentContent bool
|
||||
conflictMissingName string
|
||||
dropAckName string
|
||||
dropAckMissingName string
|
||||
failListAfter int
|
||||
listCount int
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "token "+testToken {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == assetListPath {
|
||||
fixture.mu.Lock()
|
||||
fixture.listCount++
|
||||
if fixture.failListAfter > 0 && fixture.listCount > fixture.failListAfter {
|
||||
fixture.mu.Unlock()
|
||||
http.Error(w, "read failed", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
assets := make([]assetResponse, 0, len(fixture.files))
|
||||
for name, content := range fixture.files {
|
||||
assets = append(assets, assetResponse{ID: int64(len(assets) + 1), Name: name, Size: int64(len(content)), BrowserDownloadURL: "/files/" + urlPathEscape(name)})
|
||||
}
|
||||
fixture.mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(assets)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
fixture.mu.Lock()
|
||||
content, ok := fixture.files[name]
|
||||
fixture.mu.Unlock()
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == assetListPath {
|
||||
fixture.receiveUpload(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unexpected request", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data; boundary=") {
|
||||
http.Error(w, "multipart required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
http.Error(w, "invalid multipart body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
http.Error(w, "missing attachment field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Error(w, "read multipart file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if header.Filename != r.URL.Query().Get("name") {
|
||||
http.Error(w, "filename query mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := header.Filename
|
||||
fixture.mu.Lock()
|
||||
fixture.posts = append(fixture.posts, name)
|
||||
attempts := 0
|
||||
for _, posted := range fixture.posts {
|
||||
if posted == name {
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
if name == fixture.failName && attempts == 1 {
|
||||
fixture.mu.Unlock()
|
||||
http.Error(w, "write failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if name == fixture.dropAckMissingName {
|
||||
fixture.mu.Unlock()
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
if name == fixture.conflictMissingName {
|
||||
fixture.mu.Unlock()
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if name == fixture.conflictName && fixture.conflictDifferentContent {
|
||||
fixture.files[name] = []byte("conflicting bytes")
|
||||
fixture.mu.Unlock()
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
fixture.files[name] = append([]byte(nil), content...)
|
||||
fixture.mu.Unlock()
|
||||
if name == fixture.conflictName {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if name == fixture.dropAckName {
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
connection, _, err := hijacker.Hijack()
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))})
|
||||
}
|
||||
|
||||
func localAssets(t *testing.T, names []string) []artifact.ExpectedAsset {
|
||||
t.Helper()
|
||||
output := t.TempDir()
|
||||
assets := make([]artifact.ExpectedAsset, 0, len(names))
|
||||
for _, name := range names {
|
||||
content := []byte("binary-" + name)
|
||||
path := filepath.Join(output, name)
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := assetFor(name, content)
|
||||
sum.Content = nil
|
||||
sum.Path = path
|
||||
assets = append(assets, sum)
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func readExpectedFile(t *testing.T, asset artifact.ExpectedAsset) []byte {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(asset.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (fixture *uploadFixture) postNames() []string {
|
||||
fixture.mu.Lock()
|
||||
defer fixture.mu.Unlock()
|
||||
return append([]string(nil), fixture.posts...)
|
||||
}
|
||||
|
||||
func names(identities []AssetIdentity) []string {
|
||||
result := make([]string, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
result = append(result, identity.Filename)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertAssetCategoriesDisjoint(t *testing.T, result UploadResult) {
|
||||
t.Helper()
|
||||
groups := []struct {
|
||||
name string
|
||||
identities []AssetIdentity
|
||||
}{
|
||||
{name: "matching", identities: result.Matching},
|
||||
{name: "uploaded", identities: result.Uploaded},
|
||||
{name: "pending", identities: result.Pending},
|
||||
{name: "conflicting", identities: result.Conflicting},
|
||||
{name: "uncertain", identities: result.Uncertain},
|
||||
}
|
||||
seen := make(map[string]string)
|
||||
for _, group := range groups {
|
||||
for _, item := range group.identities {
|
||||
if previous, ok := seen[item.Filename]; ok {
|
||||
t.Errorf("asset %q appears in both %s and %s", item.Filename, previous, group.name)
|
||||
}
|
||||
seen[item.Filename] = group.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func urlPathEscape(value string) string {
|
||||
return (&url.URL{Path: value}).EscapedPath()
|
||||
}
|
||||
Reference in New Issue
Block a user