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) {
|
func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
state := &deliveryServerState{}
|
state := &deliveryServerState{}
|
||||||
|
|||||||
169
delivery_test.go
169
delivery_test.go
@@ -19,6 +19,175 @@ import (
|
|||||||
releasekit "go.maximumdirect.net/releasekit"
|
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) {
|
func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) {
|
||||||
repository := newTaggedDeliveryConsumer(t)
|
repository := newTaggedDeliveryConsumer(t)
|
||||||
temporaryRoot := t.TempDir()
|
temporaryRoot := t.TempDir()
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ profile is authoritative; credential verification precedes application downloads
|
|||||||
|
|
||||||
## Stage 9: Complete regression coverage and dependency checks
|
## Stage 9: Complete regression coverage and dependency checks
|
||||||
|
|
||||||
Status: pending.
|
Status: complete.
|
||||||
|
|
||||||
1. Finish adapting facade, CLI, HTTP fixture, and benchmark callers. Retain
|
1. Finish adapting facade, CLI, HTTP fixture, and benchmark callers. Retain
|
||||||
root/nested/major-module identities, page-only delivery, managed/prebuilt
|
root/nested/major-module identities, page-only delivery, managed/prebuilt
|
||||||
|
|||||||
@@ -226,6 +226,33 @@ func checkImportDirections(packages []listedPackage) error {
|
|||||||
}
|
}
|
||||||
visit(modulePath + "/buildinfo")
|
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 {
|
if len(violations) == 0 {
|
||||||
return nil
|
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 {
|
func checkFixture(t *testing.T, facadeSource string) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
|
|||||||
@@ -2,25 +2,12 @@ package gitea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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 {
|
type RemoteAsset struct {
|
||||||
@@ -30,18 +17,6 @@ type RemoteAsset struct {
|
|||||||
DownloadURL string
|
DownloadURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssetInspection struct {
|
|
||||||
Expected artifact.ExpectedAsset
|
|
||||||
State AssetState
|
|
||||||
Existing []RemoteAsset
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type Inspection struct {
|
|
||||||
Assets []AssetInspection
|
|
||||||
Unrelated []RemoteAsset
|
|
||||||
}
|
|
||||||
|
|
||||||
type assetResponse struct {
|
type assetResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -49,66 +24,6 @@ type assetResponse struct {
|
|||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
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) {
|
func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) {
|
||||||
if releaseID <= 0 {
|
if releaseID <= 0 {
|
||||||
return nil, failure(FailureInvalid, "list release assets", 0, errors.New("release ID must be positive"))
|
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
|
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) {
|
func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (io.ReadCloser, error) {
|
||||||
if strings.TrimSpace(rawURL) == "" {
|
if strings.TrimSpace(rawURL) == "" {
|
||||||
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
||||||
|
|||||||
@@ -2,310 +2,115 @@ package gitea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/forge"
|
||||||
)
|
)
|
||||||
|
|
||||||
const assetListPath = "/api/v1/repos/owner/project/releases/10/assets"
|
func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
|
||||||
|
|
||||||
func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) {
|
|
||||||
content := []byte("selected binary")
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
switch r.URL.Path {
|
||||||
case assetListPath:
|
case "/files/redirect":
|
||||||
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" {
|
|
||||||
http.Redirect(w, r, "/files/content", http.StatusFound)
|
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()
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if inspection.Assets[0].State != AssetMatching {
|
content, readErr := io.ReadAll(stream)
|
||||||
t.Fatalf("inspection = %#v", inspection)
|
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) {
|
func TestBackendOpenAssetDoesNotForwardAuthorizationAcrossOrigin(t *testing.T) {
|
||||||
for _, mode := range []string{"direct", "redirect", "conflicting"} {
|
for _, mode := range []string{"direct", "redirect"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
expected := assetFor("app.bin", []byte("matching bytes"))
|
var externalRequests atomic.Int32
|
||||||
var externalGets atomic.Int64
|
|
||||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
externalGets.Add(1)
|
externalRequests.Add(1)
|
||||||
if r.Header.Get("Authorization") != "" {
|
if got := r.Header.Get("Authorization"); got != "" {
|
||||||
t.Error("Gitea token reached external host")
|
t.Errorf("external authorization = %q, want empty", got)
|
||||||
}
|
}
|
||||||
content := []byte("matching bytes")
|
_, _ = io.WriteString(w, "external asset")
|
||||||
if mode == "conflicting" {
|
|
||||||
content = []byte("different bytes")
|
|
||||||
}
|
|
||||||
w.Write(content)
|
|
||||||
}))
|
}))
|
||||||
defer external.Close()
|
defer external.Close()
|
||||||
|
|
||||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assertAssetGET(t, r, "")
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||||
if r.URL.Path == assetListPath {
|
t.Errorf("origin authorization = %q, want %q", got, want)
|
||||||
download := external.URL + "/content"
|
|
||||||
if mode == "redirect" {
|
|
||||||
download = "/redirect"
|
|
||||||
}
|
|
||||||
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
|
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
|
||||||
}))
|
}))
|
||||||
defer origin.Close()
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
want := AssetMatching
|
content, readErr := io.ReadAll(stream)
|
||||||
if mode == "conflicting" {
|
closeErr := stream.Close()
|
||||||
want = AssetConflicting
|
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())
|
||||||
if inspection.Assets[0].State != want || externalGets.Load() != 1 {
|
|
||||||
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load())
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assetFor(filename string, content []byte) artifact.ExpectedAsset {
|
func TestBackendListAssetsDecodesAttachmentMetadata(t *testing.T) {
|
||||||
sum := sha256.Sum256(content)
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
return artifact.ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)}
|
_ = 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) {
|
assets, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
|
||||||
t.Helper()
|
if err != nil {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
t.Fatal(err)
|
||||||
if err := json.NewEncoder(w).Encode(assets); err != nil {
|
}
|
||||||
t.Error(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 {
|
func validCreateReleaseRequest() forge.CreateReleaseRequest {
|
||||||
return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"}
|
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 {
|
type trackedReader struct {
|
||||||
io.Reader
|
io.Reader
|
||||||
closed bool
|
closed bool
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type FailureKind string
|
type FailureKind string
|
||||||
@@ -55,14 +54,7 @@ type Destination struct {
|
|||||||
Token string
|
Token string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Request struct {
|
type release struct {
|
||||||
Destination Destination
|
|
||||||
Tag string
|
|
||||||
Commit string
|
|
||||||
Note string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Release struct {
|
|
||||||
ID int64
|
ID int64
|
||||||
URL string
|
URL string
|
||||||
TagName string
|
TagName string
|
||||||
@@ -72,12 +64,6 @@ type Release struct {
|
|||||||
Prerelease bool
|
Prerelease bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
|
||||||
Release Release
|
|
||||||
Created bool
|
|
||||||
WriteAttempted bool // a create request was issued, including reconciled creates.
|
|
||||||
}
|
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
@@ -108,109 +94,29 @@ type createReleaseRequest struct {
|
|||||||
Prerelease bool `json:"prerelease"`
|
Prerelease bool `json:"prerelease"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRelease verifies the hosted tag and finds or creates its stable release
|
func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (release, int, error) {
|
||||||
// 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) {
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != 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("Authorization", "token "+token)
|
||||||
request.Header.Set("Accept", "application/json")
|
request.Header.Set("Accept", "application/json")
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
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()
|
defer response.Body.Close()
|
||||||
if response.StatusCode == http.StatusNotFound {
|
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 {
|
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
|
var decoded releaseResponse
|
||||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
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 {
|
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
|
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))
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
if err != nil {
|
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("Authorization", "token "+token)
|
||||||
request.Header.Set("Accept", "application/json")
|
request.Header.Set("Accept", "application/json")
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
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()
|
defer response.Body.Close()
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
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
|
var decoded releaseResponse
|
||||||
if err := decodeJSON(response.Body, &decoded); err != nil {
|
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 {
|
func (c Client) httpClient() *http.Client {
|
||||||
@@ -269,28 +175,6 @@ func (c Client) httpClient() *http.Client {
|
|||||||
return &clientCopy
|
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) {
|
func parseBaseURL(raw string) (*url.URL, error) {
|
||||||
parsed, err := url.Parse(raw)
|
parsed, err := url.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,30 +186,8 @@ func parseBaseURL(raw string) (*url.URL, error) {
|
|||||||
return parsed, nil
|
return parsed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func acceptExisting(release Release, request Request) (Result, error) {
|
func (response releaseResponse) release() release {
|
||||||
if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease {
|
return release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.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 validObjectID(value string) bool {
|
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
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
"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) {
|
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 {
|
if content == nil {
|
||||||
return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required"))
|
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
|
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