Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-08-23 12:24:57 -04:00
pkg-proxy/internal/handler/pypi_test.go
oscar-broman 4fa903e01e
Enforce cooldown on artifact downloads (#240)
Cooldown filtering only ran when rewriting metadata, so a version could
be missing from the npm packument and the PyPI simple index while its
tarball stayed reachable. Lockfiles record artifact URLs verbatim, so
npm ci and pinned pip requirements reach handleDownload without ever
requesting metadata.

The shared artifact path has no publish time to check against, since
updateCacheDB upserts versions without PublishedAt and the column is
only set by enrichment. Each handler now resolves the publish time from
metadata it already fetches and returns 404 while a version is inside
the window. Versions with no usable publish time are still served, as
they are when filtering metadata.
2026-08-10 09:27:03 +01:00

294 lines
9.9 KiB
Go

package handler
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/registries/fetch"
)
func TestPyPIParseFilename(t *testing.T) {
h := &PyPIHandler{proxy: &Proxy{Logger: slog.Default()}}
tests := []struct {
filename string
wantName string
wantVersion string
}{
// Sdist formats
{"requests-2.31.0.tar.gz", "requests", "2.31.0"},
{"Django-4.2.7.tar.gz", "Django", "4.2.7"},
{"aws-sdk-1.0.0.tar.gz", "aws-sdk", "1.0.0"},
{"zipp-3.17.0.zip", "zipp", "3.17.0"},
// Additional sdist archive formats
{"lxml-4.9.3.tar.xz", "lxml", "4.9.3"},
{"docutils-0.20.1.tgz", "docutils", "0.20.1"},
{"psycopg2-2.9.9.tar.bz2", "psycopg2", "2.9.9"},
// Wheel formats
{"requests-2.31.0-py3-none-any.whl", "requests", "2.31.0"},
{"numpy-1.26.2-cp311-cp311-manylinux_2_17_x86_64.whl", "numpy", "1.26.2"},
{"cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", "cryptography", "41.0.5"},
// Wheels with a build tag must not fold the tag into the version
{"foo-1.0-1-py3-none-any.whl", "foo", "1.0"},
{"tensorflow-2.15.0-2-cp311-cp311-manylinux_2_17_x86_64.whl", "tensorflow", "2.15.0"},
// PEP 658 core-metadata sidecars resolve to the distribution they describe
{"backports_asyncio_runner-1.2.0-py3-none-any.whl.metadata", "backports_asyncio_runner", "1.2.0"},
{"requests-2.31.0-py3-none-any.whl.metadata", "requests", "2.31.0"},
{"requests-2.31.0.tar.gz.metadata", "requests", "2.31.0"},
// Eggs: {name}-{version}-py{X.Y}(-{platform})?.egg. Unescaped hyphens in
// the name must not be mistaken for the field separator before the version.
{"numpy-1.8.0-py2.7-macosx-10.9-x86_64.egg", "numpy", "1.8.0"},
{"aws-sdk-1.0.0-py3.11.egg", "aws-sdk", "1.0.0"},
{"aws-sdk-1.0.0-py2.7-macosx-10.9-x86_64.egg", "aws-sdk", "1.0.0"},
{"aws-sdk-1.0.0.egg", "aws-sdk", "1.0.0"},
// A "py{N}" component inside the name is not the interpreter field, so
// the interpreter must be located from the end of the filename.
{"django-rest-py3-1.0-py3.6.egg", "django-rest-py3", "1.0"},
// Windows installers: {name}-{version}.{platform}(-py{X.Y})?.{exe,msi}.
// The platform is not part of the version, and may contain a hyphen.
{"foo-1.0.win32-py2.0.exe", "foo", "1.0"},
{"pywin32-223.win32-py2.7.exe", "pywin32", "223"},
{"numpy-1.8.0.win-amd64-py2.7.exe", "numpy", "1.8.0"},
{"aws-sdk-1.0.0.win32-py2.7.exe", "aws-sdk", "1.0.0"},
{"pywin32-223.win32.exe", "pywin32", "223"},
{"cx_Oracle-5.1.2.win32-py2.7.msi", "cx_Oracle", "5.1.2"},
{"numpy-1.8.0.win-amd64.msi", "numpy", "1.8.0"},
// A trailing build variant belongs to neither the name nor the version.
{"cx_Oracle-5.1.2-11g.win32-py2.7.exe", "cx_Oracle", "5.1.2"},
// A prerelease version has no purely numeric field to anchor on.
{"foo-1.0b1.win32-py2.7.exe", "foo", "1.0b1"},
// Invalid
{"invalid", "", ""},
{"invalid.metadata", "", ""},
{"backports.ssl_match_hostname-3.4.0.2-py2.7.whl", "", ""},
{"invalid.exe", "", ""},
{"foo-1.0.exe", "", ""},
// An egg with an interpreter field but no version must not promote the
// trailing component of a hyphenated name to the version.
{"aws-sdk-py2.7.egg", "", ""},
}
for _, tt := range tests {
name, version := h.parseFilename(tt.filename)
if name != tt.wantName || version != tt.wantVersion {
t.Errorf("parseFilename(%q) = (%q, %q), want (%q, %q)",
tt.filename, name, version, tt.wantName, tt.wantVersion)
}
}
}
func TestPyPIRewriteJSONMetadataCooldown(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 := &PyPIHandler{
proxy: proxy,
proxyURL: "http://localhost:8080",
}
input := `{
"info": {"name": "requests"},
"releases": {
"2.30.0": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.30.0.tar.gz", "upload_time_iso_8601": "` + old + `"}],
"2.31.0": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz", "upload_time_iso_8601": "` + recent + `"}]
},
"urls": [{"url": "https://files.pythonhosted.org/packages/ab/cd/requests-2.31.0.tar.gz", "upload_time_iso_8601": "` + recent + `"}]
}`
output, err := h.rewriteJSONMetadata([]byte(input))
if err != nil {
t.Fatalf("rewriteJSONMetadata failed: %v", err)
}
var result map[string]any
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
releases := result["releases"].(map[string]any)
if _, ok := releases["2.30.0"]; !ok {
t.Error("version 2.30.0 should not be filtered")
}
if _, ok := releases["2.31.0"]; ok {
t.Error("version 2.31.0 should be filtered by cooldown")
}
// urls array should be empty since the current version is filtered
urls := result["urls"].([]any)
if len(urls) != 0 {
t.Errorf("urls should be empty, got %d entries", len(urls))
}
}
// TestPyPIParseFilenameNoHashFallback guards the identifier used for caching:
// a filename that parses to an empty name makes handleDownload fall back to a
// "_hash_<digest>" package name, which surfaces as a bogus PURL in the package
// overview.
func TestPyPIParseFilenameNoHashFallback(t *testing.T) {
h := &PyPIHandler{proxy: &Proxy{Logger: slog.Default()}}
filenames := []string{
"backports_asyncio_runner-1.2.0-py3-none-any.whl",
"backports_asyncio_runner-1.2.0-py3-none-any.whl.metadata",
"backports_asyncio_runner-1.2.0.tar.gz",
}
for _, filename := range filenames {
name, version := h.parseFilename(filename)
if name != "backports_asyncio_runner" || version != "1.2.0" {
t.Errorf("parseFilename(%q) = (%q, %q), want (%q, %q)",
filename, name, version, "backports_asyncio_runner", "1.2.0")
}
}
}
func TestPyPIHandler_DownloadUpstreamURL(t *testing.T) {
proxy, _, _, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("wheel data")),
ContentType: "application/octet-stream",
}
h := NewPyPIHandler(proxy, "http://localhost")
srv := httptest.NewServer(h.Routes())
defer srv.Close()
// The path wildcard {path...} captures everything after /packages/,
// which includes "packages/" from the rewritten URL. The upstream URL
// must not double the "packages" segment.
resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if !fetcher.fetchCalled {
t.Fatal("expected fetcher to be called on cache miss")
}
want := "https://files.pythonhosted.org/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl"
if fetcher.fetchedURL != want {
t.Errorf("upstream URL = %q, want %q", fetcher.fetchedURL, want)
}
}
func TestPyPIHandler_DownloadCacheHit(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "pypi", "requests", "2.31.0",
"requests-2.31.0-py3-none-any.whl", "wheel binary data")
h := NewPyPIHandler(proxy, "http://localhost")
srv := httptest.NewServer(h.Routes())
defer srv.Close()
resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "wheel binary data" {
t.Errorf("body = %q, want %q", body, "wheel binary data")
}
}
func TestPyPIHandler_DownloadCacheMiss(t *testing.T) {
proxy, _, _, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("fetched wheel")),
ContentType: "application/octet-stream",
}
h := NewPyPIHandler(proxy, "http://localhost")
srv := httptest.NewServer(h.Routes())
defer srv.Close()
resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/newpkg-1.0.0.tar.gz")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if !fetcher.fetchCalled {
t.Error("expected fetcher to be called on cache miss")
}
}
func TestPyPIDownloadCooldown(t *testing.T) {
now := time.Now()
releases := `{"releases": {
"1.0.0": [{"upload_time_iso_8601": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `"}],
"2.0.0": [{"upload_time_iso_8601": "` + now.Add(-1*time.Hour).Format(time.RFC3339) + `"}]
}}`
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentTypeJSON)
_, _ = io.WriteString(w, releases)
}))
defer upstream.Close()
tests := []struct {
name string
filename string
wantStatus int
}{
{"published before the window serves the file", "newpkg-1.0.0.tar.gz", http.StatusOK},
{"published inside the window is withheld", "newpkg-2.0.0.tar.gz", 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("sdist data")),
ContentType: "application/octet-stream",
}
h := &PyPIHandler{
proxy: proxy,
upstreamURL: upstream.URL,
proxyURL: "http://localhost",
}
srv := httptest.NewServer(h.Routes())
defer srv.Close()
resp, err := http.Get(srv.URL + "/packages/packages/ab/cd/ef0123456789/" + tt.filename)
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")
}
})
}
}