2026-03-04 19:00:31 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-26 13:51:08 +02:00
|
|
|
"context"
|
2026-03-04 19:00:31 +00:00
|
|
|
"encoding/json"
|
2026-10-11 17:57:37 +02:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-03-04 19:00:31 +00:00
|
|
|
"log/slog"
|
2026-06-26 13:51:08 +02:00
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-04-06 16:34:33 +01:00
|
|
|
"strings"
|
2026-10-12 09:56:57 +02:00
|
|
|
"sync/atomic"
|
2026-03-04 19:00:31 +00:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-05-13 06:45:33 +01:00
|
|
|
"github.com/git-pkgs/cooldown"
|
2026-10-11 00:25:33 +02:00
|
|
|
"github.com/git-pkgs/purl"
|
2026-10-11 17:57:37 +02:00
|
|
|
"github.com/git-pkgs/registries/fetch"
|
2026-03-04 19:00:31 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestComposerRewriteMetadata(t *testing.T) {
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := `{
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/console"].([]any)
|
|
|
|
|
v := versions[0].(map[string]any)
|
|
|
|
|
dist := v["dist"].(map[string]any)
|
|
|
|
|
|
|
|
|
|
expected := "http://localhost:8080/composer/files/symfony/console/6.0.0/abc123.zip"
|
|
|
|
|
if dist["url"] != expected {
|
|
|
|
|
t.Errorf("dist url = %q, want %q", dist["url"], expected)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
func TestComposerRewriteMetadataMinifiedInheritance(t *testing.T) {
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Minified format: first version has all fields, subsequent versions
|
2026-10-10 08:31:37 +02:00
|
|
|
// only include fields that changed. The proxy keeps the format, so once
|
|
|
|
|
// Composer expands it every version still has all fields (including
|
|
|
|
|
// "name").
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"name": "symfony/console",
|
|
|
|
|
"description": "Symfony Console Component",
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "5.4.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/5.4.0/def456.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
if result["minified"] != "composer/2.0" {
|
|
|
|
|
t.Errorf("minified = %v, want the format kept", result["minified"])
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
versions := composerVersions(t, output, "symfony/console")
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
|
|
|
|
|
// Second version should have inherited the "name" and "description" fields
|
2026-10-10 08:31:37 +02:00
|
|
|
v1 := versions[1]
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
if v1["name"] != "symfony/console" {
|
|
|
|
|
t.Errorf("second version name = %v, want %q", v1["name"], "symfony/console")
|
|
|
|
|
}
|
|
|
|
|
if v1["description"] != "Symfony Console Component" {
|
|
|
|
|
t.Errorf("second version description = %v, want %q", v1["description"], "Symfony Console Component")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComposerRewriteMetadataMinifiedDevReset(t *testing.T) {
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The ~dev sentinel resets the inheritance chain for dev versions.
|
|
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"name": "symfony/console",
|
|
|
|
|
"description": "Symfony Console Component",
|
|
|
|
|
"license": ["MIT"],
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/6.0.0/abc123.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"~dev",
|
|
|
|
|
{
|
|
|
|
|
"name": "symfony/console",
|
|
|
|
|
"version": "dev-main",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://repo.packagist.org/files/symfony/console/dev-main/xyz789.zip",
|
|
|
|
|
"type": "zip"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
versions := composerVersions(t, output, "symfony/console")
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
|
|
|
|
|
if len(versions) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 versions, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dev version should NOT have inherited "license" or "description"
|
|
|
|
|
// from the tagged version (the ~dev sentinel resets inheritance).
|
2026-10-10 08:31:37 +02:00
|
|
|
devVersion := versions[1]
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
if devVersion["version"] != "dev-main" {
|
|
|
|
|
t.Errorf("dev version = %v, want %q", devVersion["version"], "dev-main")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := devVersion["license"]; ok {
|
|
|
|
|
t.Error("dev version should not have inherited license field after ~dev reset")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := devVersion["description"]; ok {
|
|
|
|
|
t.Error("dev version should not have inherited description field after ~dev reset")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 06:54:33 -05:00
|
|
|
func TestComposerRewriteMetadataUnset(t *testing.T) {
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: &Proxy{Logger: slog.Default()},
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// In the minified format, "__unset" removes a field from the inherited
|
|
|
|
|
// state. v1.29.0 has require-dev, v1.28.0 unsets it, v1.27.0 inherits the
|
|
|
|
|
// unset state. Composer rejects metadata where require-dev (or any link
|
|
|
|
|
// field) is the literal string "__unset" rather than an object.
|
|
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"venturecraft/revisionable": [
|
|
|
|
|
{
|
|
|
|
|
"name": "venturecraft/revisionable",
|
|
|
|
|
"version": "1.29.0",
|
|
|
|
|
"require": {"php": ">=5.4"},
|
|
|
|
|
"require-dev": {"orchestra/testbench": "~3.0"},
|
|
|
|
|
"dist": {"url": "https://example.com/a.zip", "type": "zip"}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "1.28.0",
|
|
|
|
|
"require-dev": "__unset"
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "1.27.0"
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "1.26.0",
|
|
|
|
|
"require-dev": {"foo/bar": "1.0"}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
versions := composerVersions(t, output, "venturecraft/revisionable")
|
2026-05-19 06:54:33 -05:00
|
|
|
if len(versions) != 4 {
|
|
|
|
|
t.Fatalf("expected 4 versions, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
byVersion := map[string]map[string]any{}
|
2026-10-10 08:31:37 +02:00
|
|
|
for _, vmap := range versions {
|
2026-05-19 06:54:33 -05:00
|
|
|
byVersion[vmap["version"].(string)] = vmap
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, ok := byVersion["1.29.0"]["require-dev"].(map[string]any); !ok {
|
|
|
|
|
t.Errorf("1.29.0 require-dev should be an object, got %T", byVersion["1.29.0"]["require-dev"])
|
|
|
|
|
}
|
|
|
|
|
if rd, ok := byVersion["1.28.0"]["require-dev"]; ok {
|
|
|
|
|
t.Errorf("1.28.0 require-dev should be absent, got %v", rd)
|
|
|
|
|
}
|
|
|
|
|
if rd, ok := byVersion["1.27.0"]["require-dev"]; ok {
|
|
|
|
|
t.Errorf("1.27.0 require-dev should be absent (inherited unset), got %v", rd)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := byVersion["1.26.0"]["require-dev"].(map[string]any); !ok {
|
|
|
|
|
t.Errorf("1.26.0 require-dev should be an object, got %T", byVersion["1.26.0"]["require-dev"])
|
|
|
|
|
}
|
|
|
|
|
if _, ok := byVersion["1.27.0"]["require"].(map[string]any); !ok {
|
|
|
|
|
t.Error("1.27.0 should still inherit require from 1.29.0")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
func TestComposerRewriteMetadataCooldownPreservesNames(t *testing.T) {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
veryOld := now.Add(-20 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
proxy := &Proxy{Logger: slog.Default()}
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Minified format where "name" only appears in first version.
|
|
|
|
|
// When cooldown filters the first version, remaining versions must
|
2026-10-10 08:31:37 +02:00
|
|
|
// still have the "name" field once Composer expands them.
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"name": "symfony/console",
|
|
|
|
|
"description": "Symfony Console Component",
|
|
|
|
|
"version": "7.0.0",
|
|
|
|
|
"time": "` + recent + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/7.0.0.zip", "type": "zip"}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"time": "` + old + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/6.0.0.zip", "type": "zip"}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "5.0.0",
|
|
|
|
|
"time": "` + veryOld + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/5.0.0.zip", "type": "zip"}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
versions := composerVersions(t, output, "symfony/console")
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
|
|
|
|
|
// v7.0.0 should be filtered by cooldown, leaving v6.0.0 and v5.0.0
|
|
|
|
|
if len(versions) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 versions after cooldown, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Both remaining versions must have the "name" field
|
2026-10-10 08:31:37 +02:00
|
|
|
for _, vmap := range versions {
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
if vmap["name"] != "symfony/console" {
|
|
|
|
|
t.Errorf("version %v missing name field, got %v", vmap["version"], vmap["name"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 16:34:33 +01:00
|
|
|
func TestComposerRewriteDistURLGitHubZipball(t *testing.T) {
|
|
|
|
|
// GitHub zipball URLs end with a bare commit hash, no file extension.
|
|
|
|
|
// The proxy must produce a filename with .zip extension so that the
|
|
|
|
|
// archives library can detect the format when browsing source.
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
url, ok := h.proxyDistURL("symfony/asset", "v7.4.8",
|
|
|
|
|
"https://api.github.com/repos/symfony/asset/zipball/d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", "zip")
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("dist URL was not rewritten")
|
2026-04-06 16:34:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The rewritten URL's filename must have a .zip extension
|
|
|
|
|
if !strings.HasSuffix(url, ".zip") {
|
|
|
|
|
t.Errorf("rewritten dist URL filename has no .zip extension: %s", url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComposerRewriteMetadataGitHubZipballFilenames(t *testing.T) {
|
|
|
|
|
// End-to-end: metadata with GitHub zipball URLs should produce
|
|
|
|
|
// download URLs that end in .zip so browse source can open them.
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := `{
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/config": [
|
|
|
|
|
{
|
|
|
|
|
"version": "v7.4.8",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39",
|
|
|
|
|
"type": "zip",
|
|
|
|
|
"reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/config"].([]any)
|
|
|
|
|
v := versions[0].(map[string]any)
|
|
|
|
|
dist := v["dist"].(map[string]any)
|
|
|
|
|
url := dist["url"].(string)
|
|
|
|
|
|
|
|
|
|
if !strings.HasSuffix(url, ".zip") {
|
|
|
|
|
t.Errorf("rewritten URL should end in .zip, got %s", url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComposerExpandMinifiedSharedDistReferences(t *testing.T) {
|
|
|
|
|
// When a minified version inherits the dist field from a previous version
|
2026-10-10 08:31:37 +02:00
|
|
|
// (i.e. it doesn't include its own dist), rewriting must still give each
|
|
|
|
|
// version a dist URL naming its own version.
|
2026-04-06 16:34:33 +01:00
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// In this minified payload, v5.3.0 does NOT include a dist field,
|
2026-10-10 08:31:37 +02:00
|
|
|
// so it inherits v5.4.0's dist. After URL rewriting, each version must
|
|
|
|
|
// have its own correct dist URL.
|
2026-04-06 16:34:33 +01:00
|
|
|
input := `{
|
|
|
|
|
"minified": "composer/2.0",
|
|
|
|
|
"packages": {
|
|
|
|
|
"vendor/pkg": [
|
|
|
|
|
{
|
|
|
|
|
"name": "vendor/pkg",
|
|
|
|
|
"version": "5.4.0",
|
|
|
|
|
"dist": {
|
|
|
|
|
"url": "https://api.github.com/repos/vendor/pkg/zipball/aaa111",
|
|
|
|
|
"type": "zip",
|
|
|
|
|
"reference": "aaa111"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "5.3.0"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
versions := composerVersions(t, output, "vendor/pkg")
|
2026-04-06 16:34:33 +01:00
|
|
|
if len(versions) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 versions, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
url1 := distURL(t, versions[0])
|
|
|
|
|
url2 := distURL(t, versions[1])
|
2026-04-06 16:34:33 +01:00
|
|
|
|
|
|
|
|
// Each version must have its own URL with its own version in the path
|
|
|
|
|
if !strings.Contains(url1, "/5.4.0/") {
|
|
|
|
|
t.Errorf("v5.4.0 dist URL should contain /5.4.0/, got %s", url1)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(url2, "/5.3.0/") {
|
|
|
|
|
t.Errorf("v5.3.0 dist URL should contain /5.3.0/, got %s", url2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The two URLs must be different
|
|
|
|
|
if url1 == url2 {
|
|
|
|
|
t.Errorf("both versions have the same dist URL (shared reference bug): %s", url1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 13:51:08 +02:00
|
|
|
// TestComposerDownloadDevVersionUsesDevMetadata is a regression test for the
|
|
|
|
|
// bug that made it impossible to install a *-dev dependency from dist.
|
|
|
|
|
//
|
|
|
|
|
// Packagist serves development versions (e.g. "3.x-dev", "dev-master") from a
|
|
|
|
|
// separate "{package}~dev.json" metadata file; the regular "{package}.json"
|
|
|
|
|
// file contains only tagged releases. The download handler used to fetch only
|
|
|
|
|
// the regular file, so it could never find the dist URL for a dev version and
|
|
|
|
|
// returned 404 — causing Composer to silently fall back to a git clone.
|
|
|
|
|
//
|
|
|
|
|
// This test serves both files from a mock upstream and asserts that:
|
|
|
|
|
// - the OLD behavior (regular file only) cannot resolve the dev version, and
|
|
|
|
|
// - the FIXED behavior (consulting the ~dev file) does.
|
|
|
|
|
func TestComposerDownloadDevVersionUsesDevMetadata(t *testing.T) {
|
|
|
|
|
const (
|
|
|
|
|
pkg = "phpmd/phpmd"
|
|
|
|
|
vendor = "phpmd"
|
|
|
|
|
name = "phpmd"
|
|
|
|
|
version = "3.x-dev"
|
|
|
|
|
distURL = "https://api.github.com/repos/phpmd/phpmd/zipball/2a9217f60aaf27bf6ddad9188f254d020ab70745"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Regular metadata: tagged releases only — no dev versions.
|
|
|
|
|
stableBody := `{
|
|
|
|
|
"packages": {
|
|
|
|
|
"phpmd/phpmd": [
|
|
|
|
|
{"version": "2.15.0", "dist": {"url": "https://example.com/2.15.0.zip", "type": "zip"}}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
// ~dev metadata: where the 3.x-dev version actually lives.
|
|
|
|
|
devBody := `{
|
|
|
|
|
"packages": {
|
|
|
|
|
"phpmd/phpmd": [
|
|
|
|
|
{"version": "3.x-dev", "dist": {"url": "` + distURL + `", "type": "zip"}}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/p2/phpmd/phpmd.json":
|
|
|
|
|
_, _ = w.Write([]byte(stableBody))
|
|
|
|
|
case "/p2/phpmd/phpmd~dev.json":
|
|
|
|
|
_, _ = w.Write([]byte(devBody))
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: testProxy(),
|
|
|
|
|
repoURL: srv.URL,
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
// OLD behavior: fetching only the regular file fails to resolve the dev
|
|
|
|
|
// version, which is what produced the 404 before the fix.
|
|
|
|
|
stableURL := srv.URL + "/p2/phpmd/phpmd.json"
|
2026-10-14 08:37:39 +02:00
|
|
|
found, err := h.findDownloadURLFromMetadata(ctx, stableURL, pkg, version)
|
2026-06-26 13:51:08 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error fetching regular metadata: %v", err)
|
|
|
|
|
}
|
2026-10-14 08:37:39 +02:00
|
|
|
if found.url != "" {
|
2026-06-26 13:51:08 +02:00
|
|
|
t.Fatalf("regular metadata unexpectedly contained dev version %q (got %q); "+
|
2026-10-14 08:37:39 +02:00
|
|
|
"the test no longer reproduces the original bug", version, found.url)
|
2026-06-26 13:51:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FIXED behavior: the handler consults the ~dev file (it is first in the
|
|
|
|
|
// candidate list for dev versions) and resolves the dist URL.
|
|
|
|
|
urls := h.metadataURLsForVersion(vendor, name, version)
|
|
|
|
|
if len(urls) == 0 || !strings.HasSuffix(urls[0], "/p2/phpmd/phpmd~dev.json") {
|
|
|
|
|
t.Fatalf("dev version should consult the ~dev metadata file first, got %v", urls)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resolved string
|
|
|
|
|
for _, u := range urls {
|
2026-10-14 08:37:39 +02:00
|
|
|
found, err := h.findDownloadURLFromMetadata(ctx, u, pkg, version)
|
2026-06-26 13:51:08 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error fetching metadata %q: %v", u, err)
|
|
|
|
|
}
|
2026-10-14 08:37:39 +02:00
|
|
|
resolved = found.url
|
2026-06-26 13:51:08 +02:00
|
|
|
if resolved != "" {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resolved != distURL {
|
|
|
|
|
t.Errorf("dev version dist URL = %q, want %q", resolved, distURL)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 17:57:37 +02:00
|
|
|
const (
|
|
|
|
|
composerCommitA = "1111111111111111111111111111111111111111"
|
|
|
|
|
composerCommitB = "2222222222222222222222222222222222222222"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// setupComposerBranch returns a caching Composer handler whose upstream lists
|
|
|
|
|
// distPath as the dist of v/p's dev-master and 1.0.0 and serves archives by
|
|
|
|
|
// request URI.
|
|
|
|
|
func setupComposerBranch(t *testing.T, distPath string, archives map[string]string) (http.Handler, *Proxy) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var srv *httptest.Server
|
|
|
|
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path == "/p2/v/p.json" || r.URL.Path == "/p2/v/p~dev.json" {
|
|
|
|
|
dist := fmt.Sprintf(`"dist":{"url":%q,"type":"zip"}`, srv.URL+distPath)
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"packages":{"v/p":[{"version":"dev-master",%s},{"version":"1.0.0",%s}]}}`, dist, dist)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
body, ok := archives[r.URL.RequestURI()]
|
|
|
|
|
if !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_, _ = io.WriteString(w, body)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(srv.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
return NewComposerHandlerWithUpstreams(proxy, "http://proxy", srv.URL, srv.URL).Routes(), proxy
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cachedComposerFile returns the cached bytes of v/p dev-master's filename,
|
|
|
|
|
// or "" when nothing is cached under it.
|
|
|
|
|
func cachedComposerFile(t *testing.T, proxy *Proxy, filename string) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
cached, err := proxy.GetCachedArtifact(context.Background(), "composer", "v/p", "dev-master", filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("reading cache for %s: %v", filename, err)
|
|
|
|
|
}
|
|
|
|
|
if cached == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = cached.Reader.Close() }()
|
|
|
|
|
data, err := io.ReadAll(cached.Reader)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("reading cached %s: %v", filename, err)
|
|
|
|
|
}
|
|
|
|
|
return string(data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestComposerDownloadPinnedBranchCommit checks that a lock file pinning an
|
|
|
|
|
// older commit of a branch gets that commit rather than the head the branch's
|
|
|
|
|
// metadata lists now, and that the head is not cached under the older name.
|
2026-10-12 09:56:57 +02:00
|
|
|
// The second round is served from the cache.
|
2026-10-11 17:57:37 +02:00
|
|
|
func TestComposerDownloadPinnedBranchCommit(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
2026-10-12 09:56:57 +02:00
|
|
|
name, distPath, filename, cached string
|
2026-10-11 17:57:37 +02:00
|
|
|
}{
|
2026-10-12 09:56:57 +02:00
|
|
|
{"github zipball", "/repos/v/p/zipball/%s", "%s.zip", "%s.zip"},
|
|
|
|
|
{"github zipball without .zip", "/repos/v/p/zipball/%s", "%s", "%s"},
|
|
|
|
|
{"bitbucket archive", "/v/p/get/%s.zip", "%s.zip", "%s.zip"},
|
|
|
|
|
{"gitlab archive", "/api/v4/projects/v%%2Fp/repository/archive.zip?sha=%s", "archive.zip?sha=%s", "archive-%s.zip"},
|
2026-10-11 17:57:37 +02:00
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
h, proxy := setupComposerBranch(t, fmt.Sprintf(tt.distPath, composerCommitB), map[string]string{
|
|
|
|
|
fmt.Sprintf(tt.distPath, composerCommitA): "commit A",
|
|
|
|
|
fmt.Sprintf(tt.distPath, composerCommitB): "commit B",
|
|
|
|
|
})
|
2026-10-12 09:56:57 +02:00
|
|
|
for range 2 {
|
|
|
|
|
for _, c := range []struct{ commit, want string }{
|
|
|
|
|
{composerCommitA, "commit A"},
|
|
|
|
|
{composerCommitB, "commit B"},
|
|
|
|
|
} {
|
|
|
|
|
filename, want := fmt.Sprintf(tt.filename, c.commit), c.want
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/dev-master/"+filename, nil))
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != want {
|
|
|
|
|
t.Errorf("%s: status %d, body %q, want 200 %q", filename, w.Code, w.Body.String(), want)
|
|
|
|
|
}
|
|
|
|
|
cached := fmt.Sprintf(tt.cached, c.commit)
|
|
|
|
|
if got := cachedComposerFile(t, proxy, cached); got != want {
|
|
|
|
|
t.Errorf("%s cached as %q, want %q", cached, got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// composerMovingBranch is an upstream whose dev-master head can move. It
|
|
|
|
|
// serves the archives of commits A and B under distPath and counts how often
|
|
|
|
|
// they are fetched.
|
|
|
|
|
type composerMovingBranch struct {
|
|
|
|
|
h http.Handler
|
|
|
|
|
proxy *Proxy
|
|
|
|
|
url string
|
|
|
|
|
head atomic.Value
|
|
|
|
|
fetches atomic.Int32
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setupComposerMovingBranch(t *testing.T, distPath string) *composerMovingBranch {
|
|
|
|
|
t.Helper()
|
|
|
|
|
b := &composerMovingBranch{}
|
|
|
|
|
b.head.Store(composerCommitA)
|
|
|
|
|
archives := map[string]string{
|
|
|
|
|
fmt.Sprintf(distPath, composerCommitA): "commit A",
|
|
|
|
|
fmt.Sprintf(distPath, composerCommitB): "commit B",
|
|
|
|
|
}
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path == "/p2/v/p.json" || r.URL.Path == "/p2/v/p~dev.json" {
|
|
|
|
|
dist := b.url + fmt.Sprintf(distPath, b.head.Load())
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"packages":{"v/p":[{"version":"dev-master","dist":{"url":%q,"type":"zip"}}]}}`, dist)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
body, ok := archives[r.URL.RequestURI()]
|
|
|
|
|
if !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
b.fetches.Add(1)
|
|
|
|
|
_, _ = io.WriteString(w, body)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
b.url = srv.URL
|
|
|
|
|
|
|
|
|
|
b.proxy, _, _, _ = setupTestProxy(t)
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(srv.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
b.proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
b.h = NewComposerHandlerWithUpstreams(b.proxy, "http://proxy", srv.URL, srv.URL).Routes()
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestComposerDownloadBranchMovesOn checks a lock file pinning A while the
|
|
|
|
|
// branch moves from A to B: A is cached while it is the head and still served
|
|
|
|
|
// from the cache afterwards, and B, the head a new lock file pins, comes from
|
|
|
|
|
// upstream.
|
|
|
|
|
func TestComposerDownloadBranchMovesOn(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name, distPath, filename, cached string
|
|
|
|
|
}{
|
|
|
|
|
{"github zipball", "/repos/v/p/zipball/%s", "%s.zip", "%s.zip"},
|
|
|
|
|
{"gitlab archive", "/api/v4/projects/v%%2Fp/repository/archive.zip?sha=%s", "archive.zip?sha=%s", "archive-%s.zip"},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
b := setupComposerMovingBranch(t, tt.distPath)
|
|
|
|
|
get := func(commit, want string, wantFetches int32) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
filename := fmt.Sprintf(tt.filename, commit)
|
2026-10-11 17:57:37 +02:00
|
|
|
w := httptest.NewRecorder()
|
2026-10-12 09:56:57 +02:00
|
|
|
b.h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/dev-master/"+filename, nil))
|
2026-10-11 17:57:37 +02:00
|
|
|
if w.Code != http.StatusOK || w.Body.String() != want {
|
|
|
|
|
t.Errorf("%s: status %d, body %q, want 200 %q", filename, w.Code, w.Body.String(), want)
|
|
|
|
|
}
|
2026-10-12 09:56:57 +02:00
|
|
|
if got := cachedComposerFile(t, b.proxy, fmt.Sprintf(tt.cached, commit)); got != want {
|
2026-10-11 17:57:37 +02:00
|
|
|
t.Errorf("%s cached as %q, want %q", filename, got, want)
|
|
|
|
|
}
|
2026-10-12 09:56:57 +02:00
|
|
|
if n := b.fetches.Load(); n != wantFetches {
|
|
|
|
|
t.Errorf("after %s: %d upstream fetches, want %d", filename, n, wantFetches)
|
|
|
|
|
}
|
2026-10-11 17:57:37 +02:00
|
|
|
}
|
2026-10-12 09:56:57 +02:00
|
|
|
get(composerCommitA, "commit A", 1)
|
|
|
|
|
b.head.Store(composerCommitB)
|
|
|
|
|
get(composerCommitA, "commit A", 1)
|
|
|
|
|
get(composerCommitB, "commit B", 2)
|
|
|
|
|
get(composerCommitB, "commit B", 2)
|
2026-10-11 17:57:37 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 09:56:57 +02:00
|
|
|
// TestComposerDownloadGitLabSharedName checks that an archive an older proxy
|
|
|
|
|
// cached under GitLab's shared archive.zip name is dropped once a commit is
|
|
|
|
|
// fetched under its own name.
|
|
|
|
|
func TestComposerDownloadGitLabSharedName(t *testing.T) {
|
|
|
|
|
distPath := "/api/v4/projects/v%%2Fp/repository/archive.zip?sha=%s"
|
|
|
|
|
b := setupComposerMovingBranch(t, distPath)
|
|
|
|
|
shared, err := b.proxy.GetOrFetchArtifactFromURL(context.Background(), "composer", "v/p", "dev-master",
|
|
|
|
|
"archive.zip", b.url+fmt.Sprintf(distPath, composerCommitB))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("seeding shared entry: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_ = shared.Reader.Close()
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
b.h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/dev-master/archive.zip?sha="+composerCommitA, nil))
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != "commit A" {
|
|
|
|
|
t.Errorf("status %d, body %q, want 200 %q", w.Code, w.Body.String(), "commit A")
|
|
|
|
|
}
|
|
|
|
|
if got := cachedComposerFile(t, b.proxy, "archive.zip"); got != "" {
|
|
|
|
|
t.Errorf("archive.zip still cached as %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestComposerDownloadCachedFromOtherCommit checks that an archive an older
|
|
|
|
|
// proxy cached under a commit's name but fetched from another commit's URL is
|
|
|
|
|
// fetched again, while a correct entry is served from the cache.
|
|
|
|
|
func TestComposerDownloadCachedFromOtherCommit(t *testing.T) {
|
|
|
|
|
zipball := "/repos/v/p/zipball/"
|
|
|
|
|
var fetches atomic.Int32
|
|
|
|
|
var srv *httptest.Server
|
|
|
|
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/p2/v/p.json", "/p2/v/p~dev.json":
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"packages":{"v/p":[{"version":"dev-master","dist":{"url":%q,"type":"zip"}}]}}`, srv.URL+zipball+composerCommitB)
|
|
|
|
|
case zipball + composerCommitA:
|
|
|
|
|
fetches.Add(1)
|
|
|
|
|
_, _ = io.WriteString(w, "commit A")
|
|
|
|
|
case zipball + composerCommitB:
|
|
|
|
|
fetches.Add(1)
|
|
|
|
|
_, _ = io.WriteString(w, "commit B")
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(srv.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
h := NewComposerHandlerWithUpstreams(proxy, "http://proxy", srv.URL, srv.URL).Routes()
|
|
|
|
|
|
|
|
|
|
// What proxy versions before the pinned-commit fix stored: the head
|
|
|
|
|
// under the name of the commit the lock file pins.
|
|
|
|
|
stale, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "composer", "v/p", "dev-master",
|
|
|
|
|
composerCommitA+".zip", srv.URL+zipball+composerCommitB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("seeding stale entry: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_ = stale.Reader.Close()
|
|
|
|
|
fetches.Store(0)
|
|
|
|
|
|
|
|
|
|
for range 2 {
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/dev-master/"+composerCommitA+".zip", nil))
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != "commit A" {
|
|
|
|
|
t.Errorf("status %d, body %q, want 200 %q", w.Code, w.Body.String(), "commit A")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if got := cachedComposerFile(t, proxy, composerCommitA+".zip"); got != "commit A" {
|
|
|
|
|
t.Errorf("cached as %q, want %q", got, "commit A")
|
|
|
|
|
}
|
|
|
|
|
if n := fetches.Load(); n != 1 {
|
|
|
|
|
t.Errorf("upstream archive fetched %d times, want 1", n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestComposerDownloadGitLabTaggedRelease checks that a tagged release on
|
|
|
|
|
// GitLab is only served as the commit its metadata lists.
|
|
|
|
|
func TestComposerDownloadGitLabTaggedRelease(t *testing.T) {
|
|
|
|
|
archive := "/api/v4/projects/v%2Fp/repository/archive.zip?sha="
|
|
|
|
|
h, _ := setupComposerBranch(t, archive+composerCommitB, map[string]string{
|
|
|
|
|
archive + composerCommitA: "commit A",
|
|
|
|
|
archive + composerCommitB: "commit B",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/1.0.0/archive.zip?sha="+composerCommitA, nil))
|
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("other commit: status %d, body %q, want 404", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/1.0.0/archive.zip?sha="+composerCommitB, nil))
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != "commit B" {
|
|
|
|
|
t.Errorf("listed commit: status %d, body %q, want 200 %q", w.Code, w.Body.String(), "commit B")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestComposerDownloadBranchWithSlash checks that a branch whose name holds a
|
|
|
|
|
// slash gets a dist URL the download route matches.
|
|
|
|
|
func TestComposerDownloadBranchWithSlash(t *testing.T) {
|
|
|
|
|
const version = "dev-feature/x"
|
|
|
|
|
var srv *httptest.Server
|
|
|
|
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path == "/zip/x.zip" {
|
|
|
|
|
_, _ = io.WriteString(w, "feature")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"packages":{"v/p":[{"version":%q,"dist":{"url":%q,"type":"zip"}}]}}`, version, srv.URL+"/zip/x.zip")
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(srv.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
h := NewComposerHandlerWithUpstreams(proxy, "http://proxy", srv.URL, srv.URL)
|
|
|
|
|
|
|
|
|
|
distURL, ok := h.proxyDistURL("v/p", version, srv.URL+"/zip/x.zip", "zip")
|
|
|
|
|
if want := "http://proxy/composer/files/v/p/dev-feature%2Fx/x.zip"; !ok || distURL != want {
|
|
|
|
|
t.Fatalf("dist URL = %q, want %q", distURL, want)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(distURL, "http://proxy/composer"), nil))
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != "feature" {
|
|
|
|
|
t.Errorf("status %d, body %q, want 200 %q", w.Code, w.Body.String(), "feature")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 17:57:37 +02:00
|
|
|
// TestComposerDownloadUnlistedFile checks that a file the metadata no longer
|
|
|
|
|
// lists, and that names no commit to fetch instead, is a 404 rather than the
|
|
|
|
|
// listed archive cached under the requested name.
|
|
|
|
|
func TestComposerDownloadUnlistedFile(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name, distPath, listed, stale string
|
|
|
|
|
}{
|
|
|
|
|
{"plain name", "/dist/main.zip", "main.zip", "old.zip"},
|
|
|
|
|
{"escaped name", "/dist/p%2Bdev-main.zip", "p%2Bdev-main.zip", "old.zip"},
|
2026-10-12 09:56:57 +02:00
|
|
|
{"gitlab archive with other query", "/api/v4/projects/v%2Fp/repository/archive.zip?sha=" + composerCommitB,
|
|
|
|
|
"archive.zip?sha=" + composerCommitB, "archive.zip?sha=" + composerCommitA + "&path=src"},
|
2026-10-11 17:57:37 +02:00
|
|
|
{"branch name for a commit", "/repos/v/p/zipball/" + composerCommitB, composerCommitB + ".zip", "master.zip"},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
h, proxy := setupComposerBranch(t, tt.distPath, map[string]string{tt.distPath: "head"})
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/dev-master/"+tt.stale, nil))
|
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("%s: status %d, body %q, want 404", tt.stale, w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
staleName, _, _ := strings.Cut(tt.stale, "?")
|
|
|
|
|
if got := cachedComposerFile(t, proxy, staleName); got != "" {
|
|
|
|
|
t.Errorf("%s cached as %q, want nothing", staleName, got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/dev-master/"+tt.listed, nil))
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != "head" {
|
|
|
|
|
t.Errorf("%s: status %d, body %q, want 200 %q", tt.listed, w.Code, w.Body.String(), "head")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestComposerDownloadTaggedReleaseCommit checks that a tagged release is only
|
|
|
|
|
// served as the commit its metadata lists, so a client cannot have another
|
|
|
|
|
// commit stored as that release.
|
|
|
|
|
func TestComposerDownloadTaggedReleaseCommit(t *testing.T) {
|
|
|
|
|
zipball := "/repos/v/p/zipball/"
|
|
|
|
|
h, proxy := setupComposerBranch(t, zipball+composerCommitB, map[string]string{
|
|
|
|
|
zipball + composerCommitA: "commit A",
|
|
|
|
|
zipball + composerCommitB: "commit B",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/1.0.0/"+composerCommitA+".zip", nil))
|
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("other commit: status %d, body %q, want 404", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
cached, err := proxy.GetCachedArtifact(context.Background(), "composer", "v/p", "1.0.0", composerCommitA+".zip")
|
|
|
|
|
if err != nil || cached != nil {
|
|
|
|
|
t.Errorf("other commit cached as 1.0.0: %v, %v", cached, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files/v/p/1.0.0/"+composerCommitB+".zip", nil))
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != "commit B" {
|
|
|
|
|
t.Errorf("listed commit: status %d, body %q, want 200 %q", w.Code, w.Body.String(), "commit B")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestComposerUpstreamDistURL(t *testing.T) {
|
|
|
|
|
const zipball = "https://api.github.com/repos/v/p/zipball/"
|
2026-10-12 09:56:57 +02:00
|
|
|
const gitlab = "https://gitlab.com/api/v4/projects/v%2Fp/repository/archive.zip?sha="
|
2026-10-11 17:57:37 +02:00
|
|
|
sha256A := strings.Repeat("a", 64)
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name, listed, distType, filename, want string
|
|
|
|
|
}{
|
|
|
|
|
{"listed file", zipball + composerCommitB, "zip", composerCommitB + ".zip", zipball + composerCommitB},
|
|
|
|
|
{"older commit", zipball + composerCommitB, "zip", composerCommitA + ".zip", zipball + composerCommitA},
|
|
|
|
|
{"sha256 commit", zipball + composerCommitB, "zip", sha256A + ".zip", zipball + sha256A},
|
|
|
|
|
{"bare listed commit", zipball + composerCommitB, "zip", composerCommitB, zipball + composerCommitB},
|
|
|
|
|
{"bare older commit", zipball + composerCommitB, "zip", composerCommitA, zipball + composerCommitA},
|
|
|
|
|
{"tar dist keeps bare name", zipball + composerCommitB, "tar", composerCommitA, zipball + composerCommitA},
|
|
|
|
|
{"short hash", zipball + composerCommitB, "zip", "1111111.zip", ""},
|
|
|
|
|
{"upper-case hash", zipball + composerCommitB, "zip", strings.ToUpper(sha256A) + ".zip", ""},
|
|
|
|
|
{"other name", "https://example.com/dist/main.zip", "zip", "old.zip", ""},
|
2026-10-12 09:56:57 +02:00
|
|
|
{"gitlab older commit", gitlab + composerCommitB, "zip", "archive.zip?sha=" + composerCommitA, gitlab + composerCommitA},
|
|
|
|
|
{"gitlab short hash", gitlab + composerCommitB, "zip", "archive.zip?sha=1111111", ""},
|
|
|
|
|
{"gitlab other archive", gitlab + composerCommitB, "zip", "other.zip?sha=" + composerCommitA, ""},
|
2026-10-11 17:57:37 +02:00
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got, ok := upstreamDistURL(tt.listed, tt.distType, tt.filename, true)
|
|
|
|
|
if got != tt.want || ok != (tt.want != "") {
|
|
|
|
|
t.Errorf("upstreamDistURL(%q, %q) = %q, %v; want %q", tt.listed, tt.filename, got, ok, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if got, ok := upstreamDistURL(zipball+composerCommitB, "zip", composerCommitA+".zip", false); ok {
|
|
|
|
|
t.Errorf("tagged release rebuilt to %q, want only the listed archive", got)
|
|
|
|
|
}
|
2026-10-12 09:56:57 +02:00
|
|
|
if got, ok := upstreamDistURL(gitlab+composerCommitB, "zip", "archive.zip?sha="+composerCommitA, false); ok {
|
|
|
|
|
t.Errorf("tagged GitLab release rebuilt to %q, want only the listed archive", got)
|
|
|
|
|
}
|
2026-10-11 17:57:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:00:31 +00:00
|
|
|
func TestComposerRewriteMetadataCooldown(t *testing.T) {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
proxy := &Proxy{Logger: slog.Default()}
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
|
|
|
|
|
h := &ComposerHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
proxyURL: "http://localhost:8080",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := `{
|
|
|
|
|
"packages": {
|
|
|
|
|
"symfony/console": [
|
|
|
|
|
{
|
|
|
|
|
"version": "5.0.0",
|
|
|
|
|
"time": "` + old + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/5.0.0.zip", "type": "zip"}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"version": "6.0.0",
|
|
|
|
|
"time": "` + recent + `",
|
|
|
|
|
"dist": {"url": "https://repo.packagist.org/6.0.0.zip", "type": "zip"}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
output, err := h.rewriteMetadata([]byte(input))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("rewriteMetadata failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
|
|
|
t.Fatalf("failed to parse output: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packages := result["packages"].(map[string]any)
|
|
|
|
|
versions := packages["symfony/console"].([]any)
|
|
|
|
|
|
|
|
|
|
if len(versions) != 1 {
|
|
|
|
|
t.Fatalf("expected 1 version after cooldown, got %d", len(versions))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v := versions[0].(map[string]any)
|
|
|
|
|
if v["version"] != "5.0.0" {
|
|
|
|
|
t.Errorf("expected version 5.0.0, got %v", v["version"])
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-11 00:25:33 +02:00
|
|
|
|
|
|
|
|
func TestComposerRetentionKey(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
key string
|
|
|
|
|
want string
|
|
|
|
|
ok bool
|
|
|
|
|
}{
|
|
|
|
|
{"pkg:composer/symfony/console", "pkg:composer/symfony/console", true},
|
|
|
|
|
{"pkg:composer/Symfony/Console", "pkg:composer/symfony/console", true},
|
|
|
|
|
{"pkg:composer/console", "", false},
|
|
|
|
|
{"pkg:composer/symfony/console/extra", "", false},
|
|
|
|
|
{"pkg:npm/symfony/console", "", false},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
p, err := purl.Parse(tt.key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Parse(%s): %v", tt.key, err)
|
|
|
|
|
}
|
|
|
|
|
got, ok := composerRetentionKey(p)
|
|
|
|
|
if ok != tt.ok || (ok && got != tt.want) {
|
|
|
|
|
t.Errorf("composerRetentionKey(%s) = %q, %v, want %q, %v", tt.key, got, ok, tt.want, tt.ok)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := composerRetentionKey(nil); ok {
|
|
|
|
|
t.Error("composerRetentionKey(nil) accepted")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-14 08:37:39 +02:00
|
|
|
|
|
|
|
|
// TestComposerDownloadCooldown checks that an archive still in cooldown is
|
|
|
|
|
// withheld when a lock file requests it directly, without the metadata that
|
|
|
|
|
// would have filtered it out. In minified metadata a version inherits the
|
|
|
|
|
// time of the entry before it.
|
|
|
|
|
func TestComposerDownloadCooldown(t *testing.T) {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
var archiveRequests atomic.Int32
|
|
|
|
|
var srv *httptest.Server
|
|
|
|
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
dist := func(version string) string {
|
|
|
|
|
return fmt.Sprintf(`"dist":{"url":%q,"type":"zip"}`, srv.URL+"/archives/"+version+".zip")
|
|
|
|
|
}
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/p2/v/p.json":
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"minified":"composer/2.0","packages":{"v/p":[`+
|
|
|
|
|
`{"version":"2.0.0","time":%q,%s},`+
|
|
|
|
|
`{"version":"1.9.0",%s},`+
|
|
|
|
|
`{"version":"1.0.0","time":%q,%s}]}}`,
|
|
|
|
|
recent, dist("2.0.0"), dist("1.9.0"), old, dist("1.0.0"))
|
|
|
|
|
case "/p2/v/p~dev.json":
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
default:
|
|
|
|
|
archiveRequests.Add(1)
|
|
|
|
|
_, _ = io.WriteString(w, "archive "+r.URL.Path)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(srv.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
h := NewComposerHandlerWithUpstreams(proxy, "http://proxy", srv.URL, srv.URL).Routes()
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
version string
|
|
|
|
|
wantStatus int
|
|
|
|
|
}{
|
|
|
|
|
{"2.0.0", http.StatusNotFound},
|
|
|
|
|
{"1.9.0", http.StatusNotFound},
|
|
|
|
|
{"1.0.0", http.StatusOK},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.version, func(t *testing.T) {
|
|
|
|
|
archiveRequests.Store(0)
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/files/v/p/"+tt.version+"/"+tt.version+".zip", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != tt.wantStatus {
|
|
|
|
|
t.Fatalf("status = %d, want %d (body %q)", w.Code, tt.wantStatus, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
wantRequests := int32(0)
|
|
|
|
|
if tt.wantStatus == http.StatusOK {
|
|
|
|
|
wantRequests = 1
|
|
|
|
|
}
|
|
|
|
|
if got := archiveRequests.Load(); got != wantRequests {
|
|
|
|
|
t.Errorf("upstream archive requests = %d, want %d", got, wantRequests)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|