Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/npm_routes_test.go

301 lines
12 KiB
Go
Raw Permalink Normal View History

Add package routes for private Composer and npm registries (#426) * Add Composer package routes for private repositories - Add upstream.composer_routes mapping package name patterns to repositories - Serve matching packages only from their route, with no fallback to the default repository - Qualify metadata and artifact cache keys with the route's upstream - Give routed versions an empty notification-url so installs are not reported to notify-batch - Document routes in configuration docs, example config and README * Add npm package routes for private registries - Add upstream.npm_routes mapping package name patterns to registries - Serve matching packages only from their route, with no fallback to upstream.npm - Resolve and validate tarball URLs against the route's registry - Qualify metadata and artifact cache keys with the route's upstream - Document npm routes and the audit endpoint limitation * Share routed artifact filename qualification between handlers * Match Composer routes without the ~dev suffix Packagist serves a package's dev versions from {name}~dev.json, and the handler matched routes against the name with ~dev still attached. An exact route for example/library therefore missed /p2/example/library~dev.json, and the private package's dev metadata came from the default repository. - Match routes against the package name without ~dev, keeping it in the upstream URL and the cache key so the two files stay apart - Add a regression test with an exact route * Keep routed npm publish times out of the shared versions row The download cooldown check stores publish times under the plain version PURL, which does not say which registry a version came from. A time read from the default registry was trusted for a private version of the same name, so a private release published an hour ago passed a seven-day cooldown when the public one was a month old. - Skip the stored row for routed packages, both reading and writing, and take the publish time from the route's metadata, which is cached under the route - This also stops a private time from holding back the public version, and a route's previous upstream from lending its times to the new one - Cover adding a route after a public download, the reverse order, and moving a route to another upstream
2026-10-11 19:06:18 +02:00
package handler
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/registries/fetch"
)
// npmUpstreamRecorder serves canned packuments and records which paths were
// requested.
type npmUpstreamRecorder struct {
*httptest.Server
mu sync.Mutex
requests []string
}
func newNPMUpstream(t *testing.T, packuments map[string]func(baseURL string) string, status int) *npmUpstreamRecorder {
t.Helper()
u := &npmUpstreamRecorder{}
u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.mu.Lock()
u.requests = append(u.requests, r.URL.Path)
u.mu.Unlock()
if status != http.StatusOK {
w.WriteHeader(status)
return
}
packument, ok := packuments[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, packument(u.URL))
}))
t.Cleanup(u.Close)
return u
}
func (u *npmUpstreamRecorder) requested() []string {
u.mu.Lock()
defer u.mu.Unlock()
return append([]string(nil), u.requests...)
}
func routedTestPackument(version, tarballBase string) func(string) string {
return func(baseURL string) string {
return `{"name":"@example/widgets","dist-tags":{"latest":"` + version + `"},"versions":{"` + version +
`":{"version":"` + version + `","dist":{"tarball":"` + baseURL + tarballBase + `/@example/widgets/-/widgets-` + version + `.tgz"}}}}`
}
}
const routedNPMPath = "/registry/@example/widgets"
func newRoutedNPMHandler(proxy *Proxy, public, private *npmUpstreamRecorder) *NPMHandler {
return NewNPMHandler(proxy, "http://proxy.test", public.URL).
WithPackageRoutes(map[string]string{"@example/*": private.URL + "/registry"})
}
func TestNPMRoutedMetadataComesFromRouteOnly(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/@example%2fwidgets", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if got := public.requested(); len(got) != 0 {
t.Errorf("default registry was queried for a routed package: %v", got)
}
if strings.Contains(w.Body.String(), "9.9.9") {
t.Errorf("routed package served the default registry's metadata: %s", w.Body.String())
}
wantTarball := "http://proxy.test/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz"
if !strings.Contains(w.Body.String(), wantTarball) {
t.Errorf("tarball not rewritten to %q: %s", wantTarball, w.Body.String())
}
}
func TestNPMRoutedMetadataDoesNotFallBack(t *testing.T) {
tests := []struct {
name string
privateStatus int
wantStatus int
}{
{"package missing on route", http.StatusOK, http.StatusNotFound},
{"route rejects credentials", http.StatusUnauthorized, http.StatusBadGateway},
{"route unavailable", http.StatusInternalServerError, http.StatusBadGateway},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{}, tt.privateStatus)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/@example%2fwidgets", nil, nil)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if got := public.requested(); len(got) != 0 {
t.Errorf("default registry was queried for a routed package: %v", got)
}
})
}
}
func TestNPMRoutedMetadataIgnoresDefaultRegistryCache(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
proxy.CacheMetadata = true
proxy.MetadataTTL = time.Hour
if w := serveNPM(t, NewNPMHandler(proxy, "http://proxy.test", public.URL), http.MethodGet, "/@example%2fwidgets", nil, nil); w.Code != http.StatusOK {
t.Fatalf("priming the default registry cache: status %d", w.Code)
}
w := serveNPM(t, newRoutedNPMHandler(proxy, public, private), http.MethodGet, "/@example%2fwidgets", nil, nil)
if strings.Contains(w.Body.String(), "9.9.9") {
t.Errorf("routed package served metadata cached from the default registry: %s", w.Body.String())
}
}
func TestNPMUnroutedMetadataUsesDefaultRegistry(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/lodash": routedTestPackument("4.17.21", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/lodash", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
if got := private.requested(); len(got) != 0 {
t.Errorf("route was queried for an unrouted package: %v", got)
}
}
func TestNPMRoutedDownloadResolvesFromRoute(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("1.0.0", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("package")),
ContentType: "application/gzip",
}
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if want := private.URL + "/registry/@example/widgets/-/widgets-1.0.0.tgz"; artifactFetcher.fetchedURL != want {
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
}
if got := public.requested(); len(got) != 0 {
t.Errorf("default registry was queried for a routed download: %v", got)
}
}
func TestNPMRoutedDownloadWithoutMetadataStaysOnRoute(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{}, http.StatusInternalServerError)
proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("package")),
ContentType: "application/gzip",
}
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
Add package routes for private Composer and npm registries (#426) * Add Composer package routes for private repositories - Add upstream.composer_routes mapping package name patterns to repositories - Serve matching packages only from their route, with no fallback to the default repository - Qualify metadata and artifact cache keys with the route's upstream - Give routed versions an empty notification-url so installs are not reported to notify-batch - Document routes in configuration docs, example config and README * Add npm package routes for private registries - Add upstream.npm_routes mapping package name patterns to registries - Serve matching packages only from their route, with no fallback to upstream.npm - Resolve and validate tarball URLs against the route's registry - Qualify metadata and artifact cache keys with the route's upstream - Document npm routes and the audit endpoint limitation * Share routed artifact filename qualification between handlers * Match Composer routes without the ~dev suffix Packagist serves a package's dev versions from {name}~dev.json, and the handler matched routes against the name with ~dev still attached. An exact route for example/library therefore missed /p2/example/library~dev.json, and the private package's dev metadata came from the default repository. - Match routes against the package name without ~dev, keeping it in the upstream URL and the cache key so the two files stay apart - Add a regression test with an exact route * Keep routed npm publish times out of the shared versions row The download cooldown check stores publish times under the plain version PURL, which does not say which registry a version came from. A time read from the default registry was trusted for a private version of the same name, so a private release published an hour ago passed a seven-day cooldown when the public one was a month old. - Skip the stored row for routed packages, both reading and writing, and take the publish time from the route's metadata, which is cached under the route - This also stops a private time from holding back the public version, and a route's previous upstream from lending its times to the new one - Cover adding a route after a public download, the reverse order, and moving a route to another upstream
2026-10-11 19:06:18 +02:00
// Without the route's metadata the tarball can't be verified, so it is
// not fetched at all, and in particular not from the default registry.
if w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", w.Code)
}
if artifactFetcher.fetchedURL != "" {
t.Errorf("fetched URL = %q, want none", artifactFetcher.fetchedURL)
Add package routes for private Composer and npm registries (#426) * Add Composer package routes for private repositories - Add upstream.composer_routes mapping package name patterns to repositories - Serve matching packages only from their route, with no fallback to the default repository - Qualify metadata and artifact cache keys with the route's upstream - Give routed versions an empty notification-url so installs are not reported to notify-batch - Document routes in configuration docs, example config and README * Add npm package routes for private registries - Add upstream.npm_routes mapping package name patterns to registries - Serve matching packages only from their route, with no fallback to upstream.npm - Resolve and validate tarball URLs against the route's registry - Qualify metadata and artifact cache keys with the route's upstream - Document npm routes and the audit endpoint limitation * Share routed artifact filename qualification between handlers * Match Composer routes without the ~dev suffix Packagist serves a package's dev versions from {name}~dev.json, and the handler matched routes against the name with ~dev still attached. An exact route for example/library therefore missed /p2/example/library~dev.json, and the private package's dev metadata came from the default repository. - Match routes against the package name without ~dev, keeping it in the upstream URL and the cache key so the two files stay apart - Add a regression test with an exact route * Keep routed npm publish times out of the shared versions row The download cooldown check stores publish times under the plain version PURL, which does not say which registry a version came from. A time read from the default registry was trusted for a private version of the same name, so a private release published an hour ago passed a seven-day cooldown when the public one was a month old. - Skip the stored row for routed packages, both reading and writing, and take the publish time from the route's metadata, which is cached under the route - This also stops a private time from holding back the public version, and a route's previous upstream from lending its times to the new one - Cover adding a route after a public download, the reverse order, and moving a route to another upstream
2026-10-11 19:06:18 +02:00
}
}
func TestNPMRoutedDownloadIgnoresDefaultRegistryArtifact(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("1.0.0", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("public package")),
ContentType: "application/gzip",
}
if w := serveNPM(t, NewNPMHandler(proxy, "http://proxy.test", public.URL), http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil); w.Code != http.StatusOK {
t.Fatalf("priming the artifact cache: status %d; body: %s", w.Code, w.Body.String())
}
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("private package")),
ContentType: "application/gzip",
}
w := serveNPM(t, newRoutedNPMHandler(proxy, public, private), http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
if w.Body.String() != "private package" {
t.Errorf("routed download served %q, want the routed registry's package", w.Body.String())
}
}
// timedTestPackument is routedTestPackument with a publish time for the
// version, published age ago.
func timedTestPackument(version, tarballBase string, age time.Duration) func(string) string {
published := time.Now().Add(-age).UTC().Format(time.RFC3339)
return func(baseURL string) string {
return `{"name":"@example/widgets","dist-tags":{"latest":"` + version + `"},"versions":{"` + version +
`":{"version":"` + version + `","dist":{"tarball":"` + baseURL + tarballBase + `/@example/widgets/-/widgets-` + version + `.tgz"}}},` +
`"time":{"` + version + `":"` + published + `"}}`
}
}
// TestNPMRoutedCooldownUsesRoutePublishTimes is a regression test: the
// download cooldown check stored publish times under the package's plain
// version PURL, so a time read from one registry was trusted for the same
// version from another. A route's version published an hour ago must stay in
// a seven-day cooldown even when the default registry's version of the same
// name is a month old, whichever was downloaded first, and changing a route's
// upstream must not carry over the old one's times.
func TestNPMRoutedCooldownUsesRoutePublishTimes(t *testing.T) {
const tarball = "/@example/widgets/-/widgets-1.0.0.tgz"
month, hour := 30*24*time.Hour, time.Hour
type step struct {
handler string // "public", "route" or "other route"
wantStatus int
}
tests := []struct {
name string
steps []step
}{
{"route added after a public download", []step{
{"public", http.StatusOK},
{"route", http.StatusNotFound},
}},
{"public download after a routed one", []step{
{"route", http.StatusNotFound},
{"public", http.StatusOK},
}},
{"route moved to another upstream", []step{
{"other route", http.StatusOK},
{"route", http.StatusNotFound},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": timedTestPackument("1.0.0", "", month)}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: timedTestPackument("1.0.0", "/registry", hour)}, http.StatusOK)
old := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: timedTestPackument("1.0.0", "/registry", month)}, http.StatusOK)
proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
proxy.CacheMetadata = true
proxy.MetadataTTL = time.Hour
proxy.Cooldown = &cooldown.Config{Default: "7d"}
handlers := map[string]*NPMHandler{
"public": NewNPMHandler(proxy, "http://proxy.test", public.URL),
"route": newRoutedNPMHandler(proxy, public, private),
"other route": newRoutedNPMHandler(proxy, public, old),
}
for _, s := range tt.steps {
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("package")),
ContentType: "application/gzip",
}
w := serveNPM(t, handlers[s.handler], http.MethodGet, tarball, nil, nil)
if w.Code != s.wantStatus {
t.Errorf("%s download: status %d, want %d; body: %s", s.handler, w.Code, s.wantStatus, w.Body.String())
}
}
})
}
}