2026-10-13 13:11:49 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type recordedRequest struct {
|
2026-10-20 20:03:06 +02:00
|
|
|
method, path, query, authorization, body, expect string
|
|
|
|
|
contentLength int64
|
2026-10-13 13:11:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hostedTestServer(t *testing.T, status int, response string) (*httptest.Server, *recordedRequest) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
recorded := &recordedRequest{}
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
body, _ := io.ReadAll(r.Body)
|
|
|
|
|
*recorded = recordedRequest{
|
|
|
|
|
method: r.Method,
|
|
|
|
|
path: r.URL.EscapedPath(),
|
|
|
|
|
query: r.URL.RawQuery,
|
|
|
|
|
authorization: r.Header.Get("Authorization"),
|
|
|
|
|
body: string(body),
|
2026-10-20 20:03:06 +02:00
|
|
|
expect: r.Header.Get("Expect"),
|
|
|
|
|
contentLength: r.ContentLength,
|
2026-10-13 13:11:49 +02:00
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(status)
|
|
|
|
|
_, _ = io.WriteString(w, response)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
return srv, recorded
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hostedEnv(vars map[string]string) func(string) string {
|
|
|
|
|
return func(key string) string { return vars[key] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeArchive(t *testing.T, name, content string) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
path := filepath.Join(t.TempDir(), name)
|
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
|
|
|
t.Fatalf("writing archive: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunPush(t *testing.T) {
|
|
|
|
|
srv, recorded := hostedTestServer(t, http.StatusCreated,
|
|
|
|
|
`{"ecosystem":"npm","name":"@types/node","version":"20.1.0","filename":"node-20.1.0.tgz","sha256":"abc123","size":7}`)
|
|
|
|
|
archive := writeArchive(t, "node-20.1.0.tgz", "tarball")
|
|
|
|
|
var stdout bytes.Buffer
|
|
|
|
|
|
|
|
|
|
err := runPush([]string{"-url", srv.URL, "npm", "@types/node", "20.1.0", archive},
|
|
|
|
|
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), &stdout, srv.Client())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("runPush: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if recorded.method != http.MethodPut || recorded.path != "/api/hosted/npm/@types/node/20.1.0" {
|
|
|
|
|
t.Errorf("request = %s %s", recorded.method, recorded.path)
|
|
|
|
|
}
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
// sha256 of "tarball", so the proxy can check the upload.
|
|
|
|
|
const tarballSHA256 = "db4b4d0d1cb480bf9aeea253771c00febe627f236765fa37d6a5614f079a3aa0"
|
|
|
|
|
if recorded.authorization != "Bearer tok" || recorded.body != "tarball" || recorded.query != "sha256="+tarballSHA256 {
|
2026-10-13 13:11:49 +02:00
|
|
|
t.Errorf("authorization/body/query = %q/%q/%q", recorded.authorization, recorded.body, recorded.query)
|
|
|
|
|
}
|
2026-10-20 20:03:06 +02:00
|
|
|
if recorded.expect != "100-continue" || recorded.contentLength != int64(len("tarball")) {
|
|
|
|
|
t.Errorf("expect/content length = %q/%d, want 100-continue and the file size", recorded.expect, recorded.contentLength)
|
|
|
|
|
}
|
2026-10-13 13:11:49 +02:00
|
|
|
if out := stdout.String(); !strings.Contains(out, "node-20.1.0.tgz") || !strings.Contains(out, "abc123") {
|
|
|
|
|
t.Errorf("output = %q, want filename and sha256", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
func TestRunPushGenericNamesTheFile(t *testing.T) {
|
|
|
|
|
srv, recorded := hostedTestServer(t, http.StatusCreated,
|
|
|
|
|
`{"ecosystem":"generic","name":"tools","version":"1.2.0","filename":"tool-linux-arm64.tar.gz","sha256":"abc123","size":7}`)
|
|
|
|
|
archive := writeArchive(t, "tool-linux-arm64.tar.gz", "tarball")
|
|
|
|
|
|
|
|
|
|
err := runPush([]string{"-url", srv.URL, "generic", "tools", "1.2.0", archive},
|
|
|
|
|
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, srv.Client())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("runPush: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if recorded.path != "/api/hosted/generic/tools/1.2.0/tool-linux-arm64.tar.gz" {
|
|
|
|
|
t.Errorf("path = %q, want the file name appended", recorded.path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:11:49 +02:00
|
|
|
func TestRunPushErrors(t *testing.T) {
|
|
|
|
|
srv, _ := hostedTestServer(t, http.StatusBadRequest, `{"code":"BAD_REQUEST","message":"pushing packages is not supported for this ecosystem: cargo"}`)
|
|
|
|
|
archive := writeArchive(t, "serde-1.0.200.crate", "crate")
|
|
|
|
|
|
|
|
|
|
err := runPush([]string{"-url", srv.URL, "cargo", "serde", "1.0.200", archive}, hostedEnv(nil), io.Discard, srv.Client())
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "PROXY_HOSTED_TOKEN") {
|
|
|
|
|
t.Errorf("push without token error = %v, want a PROXY_HOSTED_TOKEN hint", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = runPush([]string{"-url", srv.URL, "cargo", "serde", "1.0.200", archive},
|
|
|
|
|
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, srv.Client())
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "not supported for this ecosystem") {
|
|
|
|
|
t.Errorf("rejected push error = %v, want the server's message", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = runPush([]string{"-url", srv.URL, "npm", "request"}, hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, srv.Client())
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "usage") {
|
|
|
|
|
t.Errorf("push with missing arguments error = %v, want usage", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunPushUsesProxyURLFromEnv(t *testing.T) {
|
|
|
|
|
srv, recorded := hostedTestServer(t, http.StatusCreated, `{}`)
|
|
|
|
|
archive := writeArchive(t, "request-2.88.3.tgz", "tarball")
|
|
|
|
|
|
|
|
|
|
err := runPush([]string{"npm", "request", "2.88.3", archive},
|
|
|
|
|
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok", "PROXY_URL": srv.URL}), io.Discard, srv.Client())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("runPush: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if recorded.path != "/api/hosted/npm/request/2.88.3" {
|
|
|
|
|
t.Errorf("path = %q", recorded.path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunList(t *testing.T) {
|
|
|
|
|
srv, recorded := hostedTestServer(t, http.StatusOK,
|
|
|
|
|
`{"ecosystem":"npm","name":"request","artifacts":[{"version":"2.88.3","filename":"request-2.88.3.tgz","sha256":"abc123","size":7,"pushed_at":"2026-10-13T10:00:00Z"}]}`)
|
|
|
|
|
var stdout bytes.Buffer
|
|
|
|
|
|
|
|
|
|
if err := runList([]string{"-url", srv.URL, "npm", "request"}, hostedEnv(nil), &stdout, srv.Client()); err != nil {
|
|
|
|
|
t.Fatalf("runList: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if recorded.method != http.MethodGet || recorded.path != "/api/hosted/npm/request" || recorded.authorization != "" {
|
|
|
|
|
t.Errorf("request = %s %s (authorization %q)", recorded.method, recorded.path, recorded.authorization)
|
|
|
|
|
}
|
|
|
|
|
if out := stdout.String(); !strings.Contains(out, "2.88.3") || !strings.Contains(out, "request-2.88.3.tgz") {
|
|
|
|
|
t.Errorf("output = %q", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunDelete(t *testing.T) {
|
|
|
|
|
srv, recorded := hostedTestServer(t, http.StatusNoContent, "")
|
|
|
|
|
|
|
|
|
|
err := runDelete([]string{"-url", srv.URL, "npm", "request", "2.88.3"},
|
|
|
|
|
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, srv.Client())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("runDelete: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if recorded.method != http.MethodDelete || recorded.path != "/api/hosted/npm/request/2.88.3" || recorded.authorization != "Bearer tok" {
|
|
|
|
|
t.Errorf("request = %s %s (authorization %q)", recorded.method, recorded.path, recorded.authorization)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
missing, _ := hostedTestServer(t, http.StatusNotFound, `{"code":"NOT_FOUND","message":"no pushed artifacts for this version"}`)
|
|
|
|
|
err = runDelete([]string{"-url", missing.URL, "npm", "request", "2.88.3"},
|
|
|
|
|
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, missing.Client())
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "no pushed artifacts") {
|
|
|
|
|
t.Errorf("delete of a missing version error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-13 15:20:57 +02:00
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
func TestRunDeleteOneFile(t *testing.T) {
|
|
|
|
|
srv, recorded := hostedTestServer(t, http.StatusNoContent, "")
|
|
|
|
|
var stdout bytes.Buffer
|
|
|
|
|
|
|
|
|
|
err := runDelete([]string{"-url", srv.URL, "generic", "tools", "1.2.0", "tool-linux-arm64.tar.gz"},
|
|
|
|
|
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), &stdout, srv.Client())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("runDelete: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if recorded.path != "/api/hosted/generic/tools/1.2.0/tool-linux-arm64.tar.gz" {
|
|
|
|
|
t.Errorf("path = %q", recorded.path)
|
|
|
|
|
}
|
|
|
|
|
if out := stdout.String(); out != "deleted generic tools@1.2.0 tool-linux-arm64.tar.gz\n" {
|
|
|
|
|
t.Errorf("output = %q", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 20:03:06 +02:00
|
|
|
func TestRunDeleteOneFileOnlyForGeneric(t *testing.T) {
|
|
|
|
|
srv, recorded := hostedTestServer(t, http.StatusNoContent, "")
|
|
|
|
|
|
|
|
|
|
err := runDelete([]string{"-url", srv.URL, "npm", "@acme", "widget", "1.0.0"},
|
|
|
|
|
hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, srv.Client())
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "only generic files") {
|
|
|
|
|
t.Errorf("npm delete with a fourth argument error = %v, want a refusal", err)
|
|
|
|
|
}
|
|
|
|
|
if recorded.method != "" {
|
|
|
|
|
t.Errorf("refused delete sent %s %s", recorded.method, recorded.path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 15:20:57 +02:00
|
|
|
func TestRunHostedDispatchesSubcommands(t *testing.T) {
|
|
|
|
|
srv, recorded := hostedTestServer(t, http.StatusOK, `{"ecosystem":"npm","name":"request","artifacts":[]}`)
|
|
|
|
|
|
|
|
|
|
if err := runHosted([]string{"list", "-url", srv.URL, "npm", "request"}, hostedEnv(nil), io.Discard, srv.Client()); err != nil {
|
|
|
|
|
t.Fatalf("runHosted list: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if recorded.method != http.MethodGet || recorded.path != "/api/hosted/npm/request" {
|
|
|
|
|
t.Errorf("request = %s %s", recorded.method, recorded.path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, args := range [][]string{nil, {"bogus"}} {
|
|
|
|
|
err := runHosted(args, hostedEnv(nil), io.Discard, srv.Client())
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "usage: proxy hosted <push|list|delete>") {
|
|
|
|
|
t.Errorf("runHosted(%q) error = %v, want the hosted usage", args, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHostedSubcommandUsageNamesTheHostedCommand(t *testing.T) {
|
|
|
|
|
err := runPush([]string{"npm"}, hostedEnv(map[string]string{"PROXY_HOSTED_TOKEN": "tok"}), io.Discard, http.DefaultClient)
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "usage: proxy hosted push") {
|
|
|
|
|
t.Errorf("push usage error = %v, want it to name proxy hosted push", err)
|
|
|
|
|
}
|
|
|
|
|
}
|