mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
1310 lines
40 KiB
Go
1310 lines
40 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"testing/iotest"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
"github.com/git-pkgs/registries/fetch"
|
|
)
|
|
|
|
const testVersion100 = "1.0.0"
|
|
|
|
func testProxy() *Proxy {
|
|
return &Proxy{
|
|
Logger: slog.Default(),
|
|
HTTPClient: http.DefaultClient,
|
|
}
|
|
}
|
|
|
|
func TestNPMExtractVersionFromFilename(t *testing.T) {
|
|
h := &NPMHandler{}
|
|
|
|
tests := []struct {
|
|
packageName string
|
|
filename string
|
|
want string
|
|
}{
|
|
{"lodash", "lodash-4.17.21.tgz", "4.17.21"},
|
|
{"@babel/core", "core-7.23.0.tgz", "7.23.0"},
|
|
{"@types/node", "node-20.10.0.tgz", "20.10.0"},
|
|
{"express", "express-4.18.2.tgz", "4.18.2"},
|
|
{"lodash", "lodash.tgz", ""}, // no version
|
|
{"lodash", "lodash-4.17.21.zip", ""}, // wrong extension
|
|
{"lodash", "other-4.17.21.tgz", ""}, // wrong package name
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := h.extractVersionFromFilename(tt.packageName, tt.filename)
|
|
if got != tt.want {
|
|
t.Errorf("extractVersionFromFilename(%q, %q) = %q, want %q",
|
|
tt.packageName, tt.filename, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNPMHandlerUsesConfiguredUpstream(t *testing.T) {
|
|
t.Run("metadata", func(t *testing.T) {
|
|
var requestPath, authHeader string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestPath = r.URL.Path
|
|
authHeader = r.Header.Get("Authorization")
|
|
if authHeader != "Bearer npm-token" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"versions":{}}`)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
authorizeUpstream(proxy, "Bearer npm-token")
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL+"/root/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
if requestPath != "/root/testpkg" {
|
|
t.Errorf("upstream path = %q, want %q", requestPath, "/root/testpkg")
|
|
}
|
|
if authHeader != "Bearer npm-token" {
|
|
t.Errorf("Authorization = %q, want %q", authHeader, "Bearer npm-token")
|
|
}
|
|
})
|
|
|
|
t.Run("download", func(t *testing.T) {
|
|
proxy, _, _, artifactFetcher := setupTestProxy(t)
|
|
artifactFetcher.artifact = &fetch.Artifact{
|
|
Body: io.NopCloser(strings.NewReader("package")),
|
|
ContentType: "application/gzip",
|
|
}
|
|
h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/testpkg/-/testpkg-1.0.0.tgz", nil)
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
want := "https://npm.example.test/root/testpkg/-/testpkg-1.0.0.tgz"
|
|
if artifactFetcher.fetchedURL != want {
|
|
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
|
|
}
|
|
})
|
|
|
|
t.Run("scoped download", func(t *testing.T) {
|
|
proxy, _, _, artifactFetcher := setupTestProxy(t)
|
|
artifactFetcher.artifact = &fetch.Artifact{
|
|
Body: io.NopCloser(strings.NewReader("package")),
|
|
ContentType: "application/gzip",
|
|
}
|
|
h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/@scope/name/-/name-1.0.0.tgz", nil)
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
want := "https://npm.example.test/root/@scope/name/-/name-1.0.0.tgz"
|
|
if artifactFetcher.fetchedURL != want {
|
|
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNPMRewriteMetadata(t *testing.T) {
|
|
h := &NPMHandler{
|
|
proxy: testProxy(),
|
|
proxyURL: "http://localhost:8080",
|
|
}
|
|
|
|
input := `{
|
|
"name": "lodash",
|
|
"versions": {
|
|
"4.17.21": {
|
|
"name": "lodash",
|
|
"version": "4.17.21",
|
|
"dist": {
|
|
"tarball": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
"shasum": "abc123"
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
output, _, err := h.rewriteMetadataKeeping("lodash", []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)
|
|
}
|
|
|
|
versions := result["versions"].(map[string]any)
|
|
v := versions["4.17.21"].(map[string]any)
|
|
dist := v["dist"].(map[string]any)
|
|
tarball := dist["tarball"].(string)
|
|
|
|
expected := "http://localhost:8080/npm/lodash/-/lodash-4.17.21.tgz"
|
|
if tarball != expected {
|
|
t.Errorf("tarball = %q, want %q", tarball, expected)
|
|
}
|
|
}
|
|
|
|
func TestNPMRewriteMetadataScopedPackage(t *testing.T) {
|
|
h := &NPMHandler{
|
|
proxy: testProxy(),
|
|
proxyURL: "http://localhost:8080",
|
|
}
|
|
|
|
input := `{
|
|
"name": "@babel/core",
|
|
"versions": {
|
|
"7.23.0": {
|
|
"name": "@babel/core",
|
|
"version": "7.23.0",
|
|
"dist": {
|
|
"tarball": "https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz"
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
output, _, err := h.rewriteMetadataKeeping("@babel/core", []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)
|
|
}
|
|
|
|
versions := result["versions"].(map[string]any)
|
|
v := versions["7.23.0"].(map[string]any)
|
|
dist := v["dist"].(map[string]any)
|
|
tarball := dist["tarball"].(string)
|
|
|
|
expected := "http://localhost:8080/npm/@babel%2Fcore/-/core-7.23.0.tgz"
|
|
if tarball != expected {
|
|
t.Errorf("tarball = %q, want %q", tarball, expected)
|
|
}
|
|
}
|
|
|
|
func TestNPMHandlerMetadataProxy(t *testing.T) {
|
|
// Create a mock upstream server
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/testpkg" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"name": "testpkg",
|
|
"versions": {
|
|
"1.0.0": {
|
|
"name": "testpkg",
|
|
"version": "1.0.0",
|
|
"dist": {
|
|
"tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"
|
|
}
|
|
}
|
|
}
|
|
}`))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
h := &NPMHandler{
|
|
proxy: testProxy(),
|
|
upstreamURL: upstream.URL,
|
|
proxyURL: "http://proxy.local",
|
|
}
|
|
|
|
// Test metadata request
|
|
req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
|
|
req.SetPathValue("name", "testpkg")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.handlePackageMetadata(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
// Check that tarball URL was rewritten
|
|
versions := result["versions"].(map[string]any)
|
|
v := versions[testVersion100].(map[string]any)
|
|
dist := v["dist"].(map[string]any)
|
|
tarball := dist["tarball"].(string)
|
|
|
|
if tarball != "http://proxy.local/npm/testpkg/-/testpkg-1.0.0.tgz" {
|
|
t.Errorf("tarball URL not rewritten correctly: %s", tarball)
|
|
}
|
|
}
|
|
|
|
func TestNPMRewriteMetadataCooldown(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 := testProxy()
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
h := &NPMHandler{
|
|
proxy: proxy,
|
|
proxyURL: "http://localhost:8080",
|
|
}
|
|
|
|
input := `{
|
|
"name": "testpkg",
|
|
"dist-tags": {"latest": "2.0.0"},
|
|
"time": {
|
|
"1.0.0": "` + old + `",
|
|
"2.0.0": "` + recent + `"
|
|
},
|
|
"versions": {
|
|
"1.0.0": {
|
|
"name": "testpkg",
|
|
"version": "1.0.0",
|
|
"dist": {
|
|
"tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"
|
|
}
|
|
},
|
|
"2.0.0": {
|
|
"name": "testpkg",
|
|
"version": "2.0.0",
|
|
"dist": {
|
|
"tarball": "https://registry.npmjs.org/testpkg/-/testpkg-2.0.0.tgz"
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
output, _, err := h.rewriteMetadataKeeping("testpkg", []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)
|
|
}
|
|
|
|
versions := result["versions"].(map[string]any)
|
|
|
|
// Old version should remain
|
|
if _, ok := versions[testVersion100]; !ok {
|
|
t.Error("version 1.0.0 should not be filtered")
|
|
}
|
|
|
|
// Recent version should be filtered
|
|
if _, ok := versions["2.0.0"]; ok {
|
|
t.Error("version 2.0.0 should be filtered by cooldown")
|
|
}
|
|
|
|
// dist-tags.latest should be updated to 1.0.0
|
|
distTags := result["dist-tags"].(map[string]any)
|
|
if distTags["latest"] != testVersion100 {
|
|
t.Errorf("dist-tags.latest = %q, want %q", distTags["latest"], testVersion100)
|
|
}
|
|
}
|
|
|
|
func TestNPMRewriteMetadataCooldownExemptPackage(t *testing.T) {
|
|
now := time.Now()
|
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
|
|
proxy := testProxy()
|
|
proxy.Cooldown = &cooldown.Config{
|
|
Default: "3d",
|
|
Packages: map[string]string{"pkg:npm/testpkg": "0"},
|
|
}
|
|
|
|
h := &NPMHandler{
|
|
proxy: proxy,
|
|
proxyURL: "http://localhost:8080",
|
|
}
|
|
|
|
input := `{
|
|
"name": "testpkg",
|
|
"time": {"1.0.0": "` + recent + `"},
|
|
"versions": {
|
|
"1.0.0": {
|
|
"name": "testpkg",
|
|
"version": "1.0.0",
|
|
"dist": {"tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"}
|
|
}
|
|
}
|
|
}`
|
|
|
|
output, _, err := h.rewriteMetadataKeeping("testpkg", []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)
|
|
}
|
|
|
|
versions := result["versions"].(map[string]any)
|
|
if _, ok := versions[testVersion100]; !ok {
|
|
t.Error("exempt package version should not be filtered")
|
|
}
|
|
}
|
|
|
|
func TestNPMHandlerUsesAbbreviatedMetadata(t *testing.T) {
|
|
var gotAccept string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAccept = r.Header.Get("Accept")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"name": "testpkg",
|
|
"versions": {
|
|
"1.0.0": {
|
|
"name": "testpkg",
|
|
"version": "1.0.0",
|
|
"dist": {
|
|
"tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"
|
|
}
|
|
}
|
|
}
|
|
}`))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
t.Run("no cooldown uses combined accept header", func(t *testing.T) {
|
|
h := &NPMHandler{
|
|
proxy: testProxy(),
|
|
upstreamURL: upstream.URL,
|
|
proxyURL: "http://proxy.local",
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
|
|
w := httptest.NewRecorder()
|
|
h.handlePackageMetadata(w, req)
|
|
|
|
if gotAccept != npmAcceptDefault {
|
|
t.Errorf("Accept = %q, want %q", gotAccept, npmAcceptDefault)
|
|
}
|
|
})
|
|
|
|
t.Run("cooldown enabled uses full metadata only", func(t *testing.T) {
|
|
proxy := testProxy()
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
h := &NPMHandler{
|
|
proxy: proxy,
|
|
upstreamURL: upstream.URL,
|
|
proxyURL: "http://proxy.local",
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
|
|
w := httptest.NewRecorder()
|
|
h.handlePackageMetadata(w, req)
|
|
|
|
if gotAccept != contentTypeJSON {
|
|
t.Errorf("Accept = %q, want %q (cooldown requires full metadata)", gotAccept, contentTypeJSON)
|
|
}
|
|
})
|
|
|
|
t.Run("full metadata option uses full metadata without cooldown", func(t *testing.T) {
|
|
proxy := testProxy()
|
|
proxy.NPMFullMetadata = true
|
|
|
|
h := &NPMHandler{
|
|
proxy: proxy,
|
|
upstreamURL: upstream.URL,
|
|
proxyURL: "http://proxy.local",
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
|
|
w := httptest.NewRecorder()
|
|
h.handlePackageMetadata(w, req)
|
|
|
|
if gotAccept != contentTypeJSON {
|
|
t.Errorf("Accept = %q, want %q (npm_full_metadata requires full metadata)", gotAccept, contentTypeJSON)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNPMHandlerMetadataNotFound(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
h := &NPMHandler{
|
|
proxy: testProxy(),
|
|
upstreamURL: upstream.URL,
|
|
proxyURL: "http://proxy.local",
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
|
|
req.SetPathValue("name", "nonexistent")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.handlePackageMetadata(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestNPMDownloadCooldown(t *testing.T) {
|
|
now := time.Now()
|
|
packument := `{
|
|
"name": "leftpad",
|
|
"dist-tags": {"latest": "2.0.0"},
|
|
"time": {
|
|
"1.0.0": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `",
|
|
"2.0.0": "` + now.Add(-1*time.Hour).Format(time.RFC3339) + `"
|
|
},
|
|
"versions": {"1.0.0": {}, "2.0.0": {}}
|
|
}`
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", contentTypeJSON)
|
|
_, _ = io.WriteString(w, packument)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
wantStatus int
|
|
}{
|
|
{"published before the window serves the tarball", testVersion100, http.StatusOK},
|
|
{"published inside the window is withheld", "2.0.0", http.StatusNotFound},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
fetcher.artifact = &fetch.Artifact{
|
|
Body: io.NopCloser(strings.NewReader("tarball data")),
|
|
ContentType: "application/octet-stream",
|
|
}
|
|
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
|
|
srv := httptest.NewServer(h.Routes())
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-" + tt.version + ".tgz")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
|
|
}
|
|
if tt.wantStatus == http.StatusNotFound && fetcher.fetchCalled {
|
|
t.Error("fetched a version that is still inside the cooldown window")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNPMDownloadCooldownDisabled(t *testing.T) {
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
h := NewNPMHandler(proxy, "http://proxy.test", "")
|
|
|
|
if h.versionInCooldown("leftpad", testVersion100, func() ([]byte, error) {
|
|
t.Fatal("cooldown must not request metadata when disabled")
|
|
return nil, nil
|
|
}) {
|
|
t.Error("versionInCooldown = true, want false when cooldown is not configured")
|
|
}
|
|
}
|
|
|
|
func TestNPMDownloadCooldownUsesStoredPublishTime(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
t.Error("metadata must not be fetched when the publish time is already stored")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
publishedAt time.Time
|
|
wantStatus int
|
|
}{
|
|
{"stored time before the window serves the tarball", testVersion100, time.Now().Add(-30 * 24 * time.Hour), http.StatusOK},
|
|
{"stored time inside the window is withheld", "2.0.0", time.Now().Add(-1 * time.Hour), http.StatusNotFound},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
proxy, db, store, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
seedPackage(t, db, store, "npm", "leftpad", tt.version, "leftpad-"+tt.version+".tgz", "tarball data")
|
|
|
|
if err := db.SetVersionPublishedAt("pkg:npm/leftpad@"+tt.version, "pkg:npm/leftpad", tt.publishedAt); err != nil {
|
|
t.Fatalf("seeding publish time failed: %v", err)
|
|
}
|
|
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
|
|
srv := httptest.NewServer(h.Routes())
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-" + tt.version + ".tgz")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNPMDownloadCooldownFetchesMetadataOnce(t *testing.T) {
|
|
now := time.Now()
|
|
packument := `{
|
|
"name": "leftpad",
|
|
"dist-tags": {"latest": "1.0.0"},
|
|
"time": {
|
|
"1.0.0": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `"
|
|
},
|
|
"versions": {"1.0.0": {}}
|
|
}`
|
|
|
|
var metadataRequests atomic.Int64
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
metadataRequests.Add(1)
|
|
w.Header().Set("Content-Type", contentTypeJSON)
|
|
_, _ = io.WriteString(w, packument)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
|
|
srv := httptest.NewServer(h.Routes())
|
|
defer srv.Close()
|
|
|
|
// The first download parses the packument once and persists the publish
|
|
// time; caching the artifact afterwards upserts the versions row without a
|
|
// publish time, which must not erase the stored value. The second download
|
|
// must answer from the stored time alone.
|
|
for i := 0; i < 2; i++ {
|
|
fetcher.artifact = &fetch.Artifact{
|
|
Body: io.NopCloser(strings.NewReader("tarball data")),
|
|
ContentType: "application/octet-stream",
|
|
}
|
|
resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-" + testVersion100 + ".tgz")
|
|
if err != nil {
|
|
t.Fatalf("request %d failed: %v", i+1, err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("request %d status = %d, want %d", i+1, resp.StatusCode, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
if got := metadataRequests.Load(); got != 1 {
|
|
t.Errorf("metadata requests = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestNPMDownloadErrorResponsesAreJSON guards against a regression where
|
|
// routing handleDownload's error path through the shared serveArtifactError
|
|
// helper silently switched npm's 404/502 tarball error bodies from JSON to
|
|
// plain text; npm clients expect a JSON {"error": "..."} body on every
|
|
// download failure, including the newer scan-blocked (403) case.
|
|
func TestNPMDownloadErrorResponsesAreJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fetchErr error
|
|
blocked bool
|
|
wantStatus int
|
|
}{
|
|
{"upstream not found", fetch.ErrNotFound, false, http.StatusNotFound},
|
|
{"upstream failure", errors.New("connection refused"), false, http.StatusBadGateway},
|
|
{"blocked by scan", nil, true, http.StatusForbidden},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
proxy.ScanSigningKey = []byte("test-signing-key")
|
|
if tt.blocked {
|
|
proxy.Scanners = newTestScanGroup(t, newTestScanServer(t, false, "malware detected").URL, false)
|
|
}
|
|
fetcher.fetchErr = tt.fetchErr
|
|
fetcher.artifact = &fetch.Artifact{
|
|
Body: io.NopCloser(strings.NewReader("tarball data")),
|
|
ContentType: "application/octet-stream",
|
|
}
|
|
|
|
h := NewNPMHandler(proxy, "http://proxy.test", "http://upstream.invalid")
|
|
srv := httptest.NewServer(h.Routes())
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-1.0.0.tgz")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != contentTypeJSON {
|
|
t.Errorf("Content-Type = %q, want %q", ct, contentTypeJSON)
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("response body is not valid JSON: %v", err)
|
|
}
|
|
if _, ok := body["error"]; !ok {
|
|
t.Errorf("response body %v missing \"error\" key", body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// newNPMAuditUpstream returns a handler pointed at a stub registry, plus the
|
|
// last request that registry saw.
|
|
func newNPMAuditUpstream(t *testing.T, respond http.HandlerFunc) (*NPMHandler, *npmAuditCapture) {
|
|
t.Helper()
|
|
|
|
capture := &npmAuditCapture{}
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capture.method = r.Method
|
|
capture.path = r.URL.Path
|
|
capture.query = r.URL.RawQuery
|
|
capture.contentType = r.Header.Get("Content-Type")
|
|
capture.contentEncoding = r.Header.Get("Content-Encoding")
|
|
capture.authorization = r.Header.Get("Authorization")
|
|
capture.body, _ = io.ReadAll(r.Body)
|
|
respond(w, r)
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
return NewNPMHandler(proxy, "http://proxy.test", upstream.URL), capture
|
|
}
|
|
|
|
type npmAuditCapture struct {
|
|
method string
|
|
path string
|
|
query string
|
|
contentType string
|
|
contentEncoding string
|
|
authorization string
|
|
body []byte
|
|
}
|
|
|
|
func npmAuditJSON(body string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, body)
|
|
}
|
|
}
|
|
|
|
func serveNPM(t *testing.T, h *NPMHandler, method, target string, body io.Reader,
|
|
headers map[string]string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequest(method, target, body)
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func TestNPMAuditRelaysRequestAndResponse(t *testing.T) {
|
|
const report = `{"actions":[],"advisories":{},"metadata":{"vulnerabilities":{"total":0}}}`
|
|
const payload = `{"name":"app","requires":{"lodash":"^4.17.21"},"dependencies":{}}`
|
|
|
|
h, got := newNPMAuditUpstream(t, npmAuditJSON(report))
|
|
w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits?foo=bar",
|
|
strings.NewReader(payload), map[string]string{"Content-Type": "application/json"})
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
if w.Body.String() != report {
|
|
t.Errorf("body = %q, want %q", w.Body.String(), report)
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("response Content-Type = %q, want application/json", ct)
|
|
}
|
|
if got.method != http.MethodPost {
|
|
t.Errorf("upstream method = %q, want POST", got.method)
|
|
}
|
|
if got.path != "/-/npm/v1/security/audits" {
|
|
t.Errorf("upstream path = %q, want /-/npm/v1/security/audits", got.path)
|
|
}
|
|
if got.query != "foo=bar" {
|
|
t.Errorf("upstream query = %q, want foo=bar", got.query)
|
|
}
|
|
if string(got.body) != payload {
|
|
t.Errorf("upstream body = %q, want %q", got.body, payload)
|
|
}
|
|
if got.contentType != "application/json" {
|
|
t.Errorf("upstream Content-Type = %q, want application/json", got.contentType)
|
|
}
|
|
}
|
|
|
|
// npm gzips its audit payload, so the bytes and the header describing them
|
|
// must travel together.
|
|
func TestNPMAuditForwardsGzippedBody(t *testing.T) {
|
|
var gzipped bytes.Buffer
|
|
zw := gzip.NewWriter(&gzipped)
|
|
if _, err := io.WriteString(zw, `{"name":"app"}`); err != nil {
|
|
t.Fatalf("writing gzip body: %v", err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("closing gzip writer: %v", err)
|
|
}
|
|
want := gzipped.Bytes()
|
|
|
|
h, got := newNPMAuditUpstream(t, npmAuditJSON(`{}`))
|
|
w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits",
|
|
bytes.NewReader(want), map[string]string{
|
|
"Content-Type": "application/json",
|
|
"Content-Encoding": "gzip",
|
|
})
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
if got.contentEncoding != "gzip" {
|
|
t.Errorf("upstream Content-Encoding = %q, want gzip", got.contentEncoding)
|
|
}
|
|
if !bytes.Equal(got.body, want) {
|
|
t.Errorf("upstream body was altered: got %d bytes, want %d", len(got.body), len(want))
|
|
}
|
|
}
|
|
|
|
func TestNPMAuditCoversAllSecurityEndpoints(t *testing.T) {
|
|
paths := []string{
|
|
"/-/npm/v1/security/audits", // pnpm audit, npm audit (full)
|
|
"/-/npm/v1/security/audits/quick", // yarn npm audit, npm audit fallback
|
|
"/-/npm/v1/security/advisories/bulk", // npm audit (npm 7+)
|
|
}
|
|
|
|
for _, path := range paths {
|
|
t.Run(path, func(t *testing.T) {
|
|
h, got := newNPMAuditUpstream(t, npmAuditJSON(`{}`))
|
|
w := serveNPM(t, h, http.MethodPost, path, strings.NewReader(`{}`), nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
if got.path != path {
|
|
t.Errorf("upstream path = %q, want %q", got.path, path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNPMAuditAppliesUpstreamAuth(t *testing.T) {
|
|
h, got := newNPMAuditUpstream(t, npmAuditJSON(`{}`))
|
|
authorizeUpstream(h.proxy, "Bearer npm-token")
|
|
|
|
serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits", strings.NewReader(`{}`), nil)
|
|
|
|
if got.authorization != "Bearer npm-token" {
|
|
t.Errorf("Authorization = %q, want %q", got.authorization, "Bearer npm-token")
|
|
}
|
|
}
|
|
|
|
func TestNPMAuditRelaysUpstreamError(t *testing.T) {
|
|
const upstreamBody = `{"error":"unauthorized"}`
|
|
|
|
h, _ := newNPMAuditUpstream(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = io.WriteString(w, upstreamBody)
|
|
})
|
|
w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits", strings.NewReader(`{}`), nil)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
if w.Body.String() != upstreamBody {
|
|
t.Errorf("body = %q, want %q", w.Body.String(), upstreamBody)
|
|
}
|
|
}
|
|
|
|
// A proxy-side failure must still be JSON, or the client reports it as a
|
|
// malformed audit response.
|
|
func TestNPMAuditUpstreamUnreachable(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
upstreamURL := upstream.URL
|
|
upstream.Close() // nothing is listening now
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstreamURL)
|
|
|
|
w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits", strings.NewReader(`{}`), nil)
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
if !json.Valid(w.Body.Bytes()) {
|
|
t.Errorf("body is not valid JSON: %q", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNPMAuditRejectsBadRequests(t *testing.T) {
|
|
t.Run("non-POST method", func(t *testing.T) {
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test")
|
|
|
|
w := serveNPM(t, h, http.MethodGet, "/-/npm/v1/security/audits", nil, nil)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
|
}
|
|
if !json.Valid(w.Body.Bytes()) {
|
|
t.Errorf("body is not valid JSON: %q", w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("body over the size cap", func(t *testing.T) {
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test")
|
|
|
|
body := strings.NewReader(strings.Repeat("a", npmSecurityMaxBody+1))
|
|
w := serveNPM(t, h, http.MethodPost, "/-/npm/v1/security/audits", body, nil)
|
|
|
|
if w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge)
|
|
}
|
|
})
|
|
|
|
t.Run("unreadable body is not reported as too large", func(t *testing.T) {
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/-/npm/v1/security/audits",
|
|
iotest.TimeoutReader(strings.NewReader("{}")))
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
if !json.Valid(w.Body.Bytes()) {
|
|
t.Errorf("body is not valid JSON: %q", w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNPMAuditDoesNotInjectUpstreamQuery(t *testing.T) {
|
|
h, got := newNPMAuditUpstream(t, npmAuditJSON(`{}`))
|
|
|
|
serveNPM(t, h, http.MethodPost,
|
|
"/-/npm/v1/security/audits%3Fevil=1", strings.NewReader(`{}`), nil)
|
|
|
|
if got.query != "" {
|
|
t.Errorf("upstream query = %q, want empty: encoded ? leaked into the query", got.query)
|
|
}
|
|
if got.path != "/-/npm/v1/security/audits?evil=1" {
|
|
t.Errorf("upstream path = %q, want the encoded ? kept in the path", got.path)
|
|
}
|
|
}
|
|
|
|
// The audit POST must go through relayResponse, not copy upstream headers
|
|
// wholesale: a relayed Connection header would be honoured downstream.
|
|
// TestRelayRoutes covers the GET paths; this covers the POST.
|
|
func TestNPMAuditStripsHopByHopHeaders(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
conn, rw, err := w.(http.Hijacker).Hijack()
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
_, _ = fmt.Fprint(rw, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"+
|
|
"Connection: X-Private\r\nX-Private: secret\r\nContent-Length: 2\r\n\r\n{}")
|
|
_ = rw.Flush()
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
downstream := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
|
|
defer downstream.Close()
|
|
|
|
resp, err := downstream.Client().Post(
|
|
downstream.URL+"/-/npm/v1/security/audits", contentTypeJSON, strings.NewReader(`{}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.Header.Get("Connection") != "" || resp.Header.Get("X-Private") != "" {
|
|
t.Errorf("connection-scoped headers leaked: %v", resp.Header)
|
|
}
|
|
}
|
|
|
|
// `npm audit signatures` reads the registry signing keys. The path used to fall
|
|
// through to the package dispatch and be escaped into a package name.
|
|
func TestNPMKeysProxiesUpstream(t *testing.T) {
|
|
const keys = `{"keys":[{"keyid":"SHA256:jl3bwswu","keytype":"ecdsa-sha2-nistp256"}]}`
|
|
|
|
var gotPath, gotAuth string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// EscapedPath, not Path: the bug escaped the slashes into a package
|
|
// name, and the server decodes %2F back into Path either way.
|
|
gotPath, gotAuth = r.URL.EscapedPath(), r.Header.Get("Authorization")
|
|
if gotPath != npmKeysPath {
|
|
w.WriteHeader(http.StatusMethodNotAllowed) // what registry.npmjs.org answers
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, keys)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
authorizeUpstream(proxy, "Bearer npm-token")
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
|
|
|
|
w := serveNPM(t, h, http.MethodGet, npmKeysPath, nil, nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
if w.Body.String() != keys {
|
|
t.Errorf("body = %q, want %q", w.Body.String(), keys)
|
|
}
|
|
if gotPath != npmKeysPath {
|
|
t.Errorf("upstream path = %q, want %q", gotPath, npmKeysPath)
|
|
}
|
|
if gotAuth != "Bearer npm-token" {
|
|
t.Errorf("Authorization = %q, want it applied", gotAuth)
|
|
}
|
|
}
|
|
|
|
// Tarball paths share the /-/ prefix with the /-/npm/v1 endpoints.
|
|
func TestNPMTarballStillRoutesToDownload(t *testing.T) {
|
|
proxy, _, _, artifactFetcher := setupTestProxy(t)
|
|
artifactFetcher.artifact = &fetch.Artifact{
|
|
Body: io.NopCloser(strings.NewReader("package")),
|
|
ContentType: "application/gzip",
|
|
}
|
|
h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test")
|
|
|
|
w := serveNPM(t, h, http.MethodGet, "/lodash/-/lodash-4.17.21.tgz", nil, nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNPMMetadataPath(t *testing.T) {
|
|
tests := []struct {
|
|
path, wantName, wantRegistry string
|
|
}{
|
|
{"", "", ""},
|
|
{"lodash", "lodash", "lodash"},
|
|
{"lodash/4.17.21", "lodash", "lodash/4.17.21"},
|
|
{"lodash/latest", "lodash", "lodash/latest"},
|
|
{"@babel/core", "@babel/core", "@babel%2Fcore"},
|
|
{"@babel/core/7.23.0", "@babel/core", "@babel%2Fcore/7.23.0"},
|
|
}
|
|
for _, tt := range tests {
|
|
name, registry := npmMetadataPath(tt.path)
|
|
if name != tt.wantName || registry != tt.wantRegistry {
|
|
t.Errorf("npmMetadataPath(%q) = (%q, %q), want (%q, %q)",
|
|
tt.path, name, registry, tt.wantName, tt.wantRegistry)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNPMVersionMetadataKeepsVersionSegment(t *testing.T) {
|
|
tests := []struct {
|
|
request, upstreamPath, wantTarball string
|
|
body string
|
|
}{
|
|
{
|
|
request: "/lodash/4.17.21",
|
|
upstreamPath: "/lodash/4.17.21",
|
|
wantTarball: "http://proxy.local/npm/lodash/-/lodash-4.17.21.tgz",
|
|
body: `{
|
|
"name": "lodash",
|
|
"version": "4.17.21",
|
|
"dist": {"tarball": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"}
|
|
}`,
|
|
},
|
|
{
|
|
request: "/@babel/core/7.23.0",
|
|
upstreamPath: "/@babel%2Fcore/7.23.0",
|
|
wantTarball: "http://proxy.local/npm/@babel%2Fcore/-/core-7.23.0.tgz",
|
|
body: `{
|
|
"name": "@babel/core",
|
|
"version": "7.23.0",
|
|
"dist": {"tarball": "https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz"}
|
|
}`,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.request, func(t *testing.T) {
|
|
var gotPath string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.EscapedPath()
|
|
if gotPath != tt.upstreamPath {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, tt.body)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
h := &NPMHandler{proxy: testProxy(), upstreamURL: upstream.URL, proxyURL: "http://proxy.local"}
|
|
w := httptest.NewRecorder()
|
|
h.handlePackageMetadata(w, httptest.NewRequest(http.MethodGet, tt.request, nil))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
|
}
|
|
if gotPath != tt.upstreamPath {
|
|
t.Fatalf("upstream path = %q, want %q", gotPath, tt.upstreamPath)
|
|
}
|
|
var result map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("parse response: %v", err)
|
|
}
|
|
if result["dist"].(map[string]any)["tarball"] != tt.wantTarball {
|
|
t.Errorf("tarball = %v", result["dist"].(map[string]any)["tarball"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNPMMetadataRejectsEmptyPath(t *testing.T) {
|
|
h := NewNPMHandler(testProxy(), "http://proxy.local", "http://unused.test")
|
|
w := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNPMVersionMetadataRejectsTraversal(t *testing.T) {
|
|
called := false
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
called = true
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
h := &NPMHandler{proxy: testProxy(), upstreamURL: upstream.URL, proxyURL: "http://proxy.local"}
|
|
req := httptest.NewRequest(http.MethodGet, "/lodash/x", nil)
|
|
req.URL.Path = "/lodash/.."
|
|
w := httptest.NewRecorder()
|
|
h.handlePackageMetadata(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String())
|
|
}
|
|
if called {
|
|
t.Fatal("upstream contacted for traversal path")
|
|
}
|
|
}
|
|
|
|
func TestNPMVersionMetadataCooldown(t *testing.T) {
|
|
proxyURL, upstreamPaths := npmCooldownMetadataHarness(t)
|
|
|
|
resp, err := http.Get(proxyURL + "/demo")
|
|
if err != nil {
|
|
t.Fatalf("packument request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("packument status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var packument map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&packument); err != nil {
|
|
t.Fatalf("decode packument: %v", err)
|
|
}
|
|
distTags := packument["dist-tags"].(map[string]any)
|
|
if distTags["latest"] != testVersion100 {
|
|
t.Fatalf("dist-tags.latest = %v, want %s", distTags["latest"], testVersion100)
|
|
}
|
|
if _, ok := packument["versions"].(map[string]any)["2.0.0"]; ok {
|
|
t.Fatal("packument should not include version 2.0.0")
|
|
}
|
|
|
|
for _, tt := range []struct {
|
|
path string
|
|
wantStatus int
|
|
wantVersion string
|
|
wantError string
|
|
}{
|
|
{"/demo/latest", http.StatusOK, testVersion100, ""},
|
|
{"/demo/1.0.0", http.StatusOK, testVersion100, ""},
|
|
{"/demo/2.0.0", http.StatusNotFound, "", "version not found"},
|
|
} {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
testNPMVersionMetadataCooldownPath(t, proxyURL, upstreamPaths, tt.path, tt.wantStatus, tt.wantVersion, tt.wantError)
|
|
})
|
|
}
|
|
}
|
|
|
|
// testNPMVersionMetadataCooldownPath is a subtest helper so the parent test stays
|
|
// under gocognit's complexity limit.
|
|
func testNPMVersionMetadataCooldownPath(t *testing.T, proxyURL string, upstreamPaths *[]string, path string, wantStatus int, wantVersion, wantError string) {
|
|
t.Helper()
|
|
before := len(*upstreamPaths)
|
|
resp, err := http.Get(proxyURL + path)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body := readNPMTestBody(t, resp.Body)
|
|
if resp.StatusCode != wantStatus {
|
|
t.Fatalf("status = %d, want %d; body: %s", resp.StatusCode, wantStatus, body)
|
|
}
|
|
for _, upstreamPath := range (*upstreamPaths)[before:] {
|
|
if upstreamPath == path {
|
|
t.Fatalf("upstream must not be called for version path %s when answering from the filtered packument", path)
|
|
}
|
|
}
|
|
if wantError != "" {
|
|
if !strings.Contains(body, wantError) {
|
|
t.Fatalf("error body should mention %q, got %s", wantError, body)
|
|
}
|
|
return
|
|
}
|
|
var versionDoc map[string]any
|
|
if err := json.Unmarshal([]byte(body), &versionDoc); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if versionDoc["version"] != wantVersion {
|
|
t.Fatalf("version = %v, want %s", versionDoc["version"], wantVersion)
|
|
}
|
|
wantTarball := "http://proxy.test/npm/demo/-/demo-" + wantVersion + ".tgz"
|
|
if versionDoc["dist"].(map[string]any)["tarball"] != wantTarball {
|
|
t.Fatalf("tarball = %v, want %s", versionDoc["dist"].(map[string]any)["tarball"], wantTarball)
|
|
}
|
|
}
|
|
|
|
func npmCooldownMetadataHarness(t *testing.T) (proxyURL string, upstreamPaths *[]string) {
|
|
t.Helper()
|
|
now := time.Now()
|
|
old := now.Add(-30 * 24 * time.Hour).Format(time.RFC3339)
|
|
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
|
packument := `{
|
|
"name": "demo",
|
|
"dist-tags": {"latest": "2.0.0"},
|
|
"time": {"1.0.0": "` + old + `", "2.0.0": "` + recent + `"},
|
|
"versions": {
|
|
"1.0.0": {
|
|
"name": "demo",
|
|
"version": "1.0.0",
|
|
"dist": {"tarball": "https://registry.npmjs.org/demo/-/demo-1.0.0.tgz"}
|
|
},
|
|
"2.0.0": {
|
|
"name": "demo",
|
|
"version": "2.0.0",
|
|
"dist": {"tarball": "https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}
|
|
}
|
|
}
|
|
}`
|
|
version200 := `{"name":"demo","version":"2.0.0","dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}}`
|
|
version100 := `{"name":"demo","version":"1.0.0","dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz"}}`
|
|
|
|
var paths []string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
paths = append(paths, r.URL.Path)
|
|
w.Header().Set("Content-Type", contentTypeJSON)
|
|
switch r.URL.Path {
|
|
case "/demo":
|
|
_, _ = io.WriteString(w, packument)
|
|
case "/demo/latest", "/demo/2.0.0":
|
|
_, _ = io.WriteString(w, version200)
|
|
case "/demo/1.0.0":
|
|
_, _ = io.WriteString(w, version100)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
srv := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
|
|
t.Cleanup(srv.Close)
|
|
return srv.URL, &paths
|
|
}
|
|
|
|
func readNPMTestBody(t *testing.T, r io.Reader) string {
|
|
b, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func TestNPMVersionMetadataDenied(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, `{
|
|
"name": "lodash",
|
|
"version": "4.17.21",
|
|
"dist": {"tarball": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"}
|
|
}`)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy := testProxy()
|
|
setTestDenylist(t, proxy, "pkg:npm/lodash@4.17.21")
|
|
h := &NPMHandler{proxy: proxy, upstreamURL: upstream.URL, proxyURL: "http://proxy.local"}
|
|
|
|
w := httptest.NewRecorder()
|
|
h.handlePackageMetadata(w, httptest.NewRequest(http.MethodGet, "/lodash/4.17.21", nil))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|