mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
PMG was forcing Accept: application/vnd.pypi.simple.v1+json on all Simple API requests regardless of client capability. Older pip versions (< 22.3) that only understand text/html would reject the JSON response, breaking installs entirely. Now checks the client's original Accept header before applying cooldown. If PEP 691 is not supported, the request passes through unchanged with a warning log recommending pip upgrade.
683 lines
22 KiB
Go
683 lines
22 KiB
Go
package interceptors
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/safedep/pmg/config"
|
|
"github.com/safedep/pmg/proxy"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// buildTestPEP691Response builds a PEP 691 JSON Simple API response for testing.
|
|
// versions maps version string to upload time for a single .tar.gz file per version.
|
|
func buildTestPEP691Response(versions map[string]time.Time) []byte {
|
|
type fileEntry struct {
|
|
Filename string `json:"filename"`
|
|
URL string `json:"url"`
|
|
UploadTime string `json:"upload-time"`
|
|
Hashes map[string]string `json:"hashes"`
|
|
}
|
|
|
|
files := make([]fileEntry, 0, len(versions))
|
|
for version, t := range versions {
|
|
files = append(files, fileEntry{
|
|
Filename: fmt.Sprintf("testpkg-%s.tar.gz", version),
|
|
URL: fmt.Sprintf("https://files.pythonhosted.org/packages/testpkg-%s.tar.gz", version),
|
|
UploadTime: t.UTC().Format(time.RFC3339Nano),
|
|
Hashes: map[string]string{"sha256": "abc123"},
|
|
})
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"meta": map[string]string{"api-version": "1.0"},
|
|
"name": "testpkg",
|
|
"files": files,
|
|
}
|
|
b, _ := json.Marshal(resp)
|
|
return b
|
|
}
|
|
|
|
func TestParsePEP691Files_ValidResponse(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
day := 24 * time.Hour
|
|
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * day),
|
|
"2.0.0": now.Add(-10 * day),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
assert.Len(t, dates, 2)
|
|
assert.Contains(t, dates, "1.0.0")
|
|
assert.Contains(t, dates, "2.0.0")
|
|
}
|
|
|
|
func TestParsePEP691Files_MultipleFilesPerVersion(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
day := 24 * time.Hour
|
|
|
|
// Two files for 1.0.0: sdist uploaded 5 days ago, wheel uploaded 3 days ago.
|
|
// parsePEP691Files must use the earliest (5 days ago).
|
|
sdistTime := now.Add(-5 * day)
|
|
wheelTime := now.Add(-3 * day)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"meta": map[string]string{"api-version": "1.0"},
|
|
"name": "testpkg",
|
|
"files": []map[string]any{
|
|
{
|
|
"filename": "testpkg-1.0.0.tar.gz",
|
|
"url": "https://files.pythonhosted.org/packages/testpkg-1.0.0.tar.gz",
|
|
"upload-time": sdistTime.Format(time.RFC3339Nano),
|
|
"hashes": map[string]string{"sha256": "abc"},
|
|
},
|
|
{
|
|
"filename": "testpkg-1.0.0-py3-none-any.whl",
|
|
"url": "https://files.pythonhosted.org/packages/testpkg-1.0.0-py3-none-any.whl",
|
|
"upload-time": wheelTime.Format(time.RFC3339Nano),
|
|
"hashes": map[string]string{"sha256": "def"},
|
|
},
|
|
},
|
|
})
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
require.Contains(t, dates, "1.0.0")
|
|
// Should use the earliest upload-time (sdist, 5 days ago)
|
|
assert.WithinDuration(t, sdistTime, dates["1.0.0"], time.Second)
|
|
}
|
|
|
|
func TestParsePEP691Files_MissingUploadTime(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"meta": map[string]string{"api-version": "1.0"},
|
|
"name": "testpkg",
|
|
"files": []map[string]any{
|
|
{
|
|
"filename": "testpkg-1.0.0.tar.gz",
|
|
"url": "https://files.pythonhosted.org/packages/testpkg-1.0.0.tar.gz",
|
|
"hashes": map[string]string{"sha256": "abc"},
|
|
},
|
|
{
|
|
"filename": "testpkg-2.0.0.tar.gz",
|
|
"url": "https://files.pythonhosted.org/packages/testpkg-2.0.0.tar.gz",
|
|
"upload-time": time.Now().Add(-10 * 24 * time.Hour).UTC().Format(time.RFC3339Nano),
|
|
"hashes": map[string]string{"sha256": "def"},
|
|
},
|
|
},
|
|
})
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, dates, "1.0.0")
|
|
assert.Contains(t, dates, "2.0.0")
|
|
}
|
|
|
|
func TestParsePEP691Files_YankedFiles(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now().UTC()
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"meta": map[string]string{"api-version": "1.0"},
|
|
"name": "testpkg",
|
|
"files": []map[string]any{
|
|
{
|
|
"filename": "testpkg-1.0.0.tar.gz",
|
|
"url": "https://files.pythonhosted.org/packages/testpkg-1.0.0.tar.gz",
|
|
"upload-time": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339Nano),
|
|
"hashes": map[string]string{"sha256": "abc"},
|
|
"yanked": true,
|
|
},
|
|
},
|
|
})
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
// Yanked file is still parsed — cooldown applies, pip handles yanked behaviour
|
|
assert.Contains(t, dates, "1.0.0")
|
|
}
|
|
|
|
func TestParsePEP691Files_InvalidJSON(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
_, err := handler.parsePEP691Files([]byte(`not-json`))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestParsePEP691Files_EmptyFiles(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"meta": map[string]string{"api-version": "1.0"},
|
|
"name": "testpkg",
|
|
"files": []any{},
|
|
})
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, dates)
|
|
}
|
|
|
|
func TestStripCooldownFiles_MixedVersions(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * day), // old — eligible
|
|
"2.0.0": now.Add(-1 * day), // too new (5d cooldown)
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, stripped, remaining := handler.stripCooldownFiles(body, dates, 5)
|
|
assert.Equal(t, 1, stripped)
|
|
assert.Equal(t, 1, remaining)
|
|
|
|
var result struct {
|
|
Files []struct {
|
|
Filename string `json:"filename"`
|
|
} `json:"files"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
|
|
filenames := make([]string, 0, len(result.Files))
|
|
for _, f := range result.Files {
|
|
filenames = append(filenames, f.Filename)
|
|
}
|
|
assert.Contains(t, filenames, "testpkg-1.0.0.tar.gz")
|
|
assert.NotContains(t, filenames, "testpkg-2.0.0.tar.gz")
|
|
}
|
|
|
|
func TestStripCooldownFiles_AllVersionsTooNew(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-1 * day),
|
|
"2.0.0": now.Add(-2 * day),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, stripped, remaining := handler.stripCooldownFiles(body, dates, 5)
|
|
assert.Equal(t, 2, stripped)
|
|
assert.Equal(t, 0, remaining)
|
|
|
|
var result struct {
|
|
Files []json.RawMessage `json:"files"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
assert.Empty(t, result.Files)
|
|
}
|
|
|
|
func TestStripCooldownFiles_NoVersionsTooNew(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-10 * day),
|
|
"2.0.0": now.Add(-20 * day),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, stripped, remaining := handler.stripCooldownFiles(body, dates, 5)
|
|
assert.Equal(t, 0, stripped)
|
|
assert.Equal(t, 2, remaining)
|
|
assert.Equal(t, body, newBody)
|
|
}
|
|
|
|
func TestStripCooldownFiles_SingleVersionInCooldown(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now()
|
|
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-1 * 24 * time.Hour),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
|
|
_, stripped, remaining := handler.stripCooldownFiles(body, dates, 5)
|
|
assert.Equal(t, 1, stripped)
|
|
assert.Equal(t, 0, remaining)
|
|
}
|
|
|
|
func TestStripCooldownFiles_MultipleFilesPerVersion_AllStripped(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now()
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"meta": map[string]string{"api-version": "1.0"},
|
|
"name": "testpkg",
|
|
"files": []map[string]any{
|
|
{
|
|
"filename": "testpkg-1.0.0.tar.gz",
|
|
"url": "https://files.pythonhosted.org/packages/testpkg-1.0.0.tar.gz",
|
|
"upload-time": now.Add(-1 * 24 * time.Hour).UTC().Format(time.RFC3339Nano),
|
|
"hashes": map[string]string{"sha256": "abc"},
|
|
},
|
|
{
|
|
"filename": "testpkg-1.0.0-py3-none-any.whl",
|
|
"url": "https://files.pythonhosted.org/packages/testpkg-1.0.0-py3-none-any.whl",
|
|
"upload-time": now.Add(-2 * 24 * time.Hour).UTC().Format(time.RFC3339Nano),
|
|
"hashes": map[string]string{"sha256": "def"},
|
|
},
|
|
},
|
|
})
|
|
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, stripped, remaining := handler.stripCooldownFiles(body, dates, 5)
|
|
assert.Equal(t, 1, stripped) // 1 version stripped
|
|
assert.Equal(t, 0, remaining)
|
|
|
|
var result struct {
|
|
Files []json.RawMessage `json:"files"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
assert.Empty(t, result.Files) // both files (sdist + wheel) removed
|
|
}
|
|
|
|
func TestStripCooldownFiles_MalformedJSON(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
body := []byte(`not-json`)
|
|
dates := map[string]time.Time{"1.0.0": time.Now().Add(-1 * time.Hour)}
|
|
|
|
newBody, stripped, _ := handler.stripCooldownFiles(body, dates, 5)
|
|
assert.Equal(t, 0, stripped)
|
|
assert.Equal(t, body, newBody)
|
|
}
|
|
|
|
func TestStripCooldownFiles_UnparseableFilename_KeepFile(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
now := time.Now()
|
|
|
|
// .egg is an unsupported extension — parseFilename will fail
|
|
// The file must be kept (fail-open), not stripped
|
|
body, _ := json.Marshal(map[string]any{
|
|
"meta": map[string]string{"api-version": "1.0"},
|
|
"name": "testpkg",
|
|
"files": []map[string]any{
|
|
{
|
|
"filename": "testpkg-1.0.0.egg",
|
|
"url": "https://files.pythonhosted.org/packages/testpkg-1.0.0.egg",
|
|
"upload-time": now.Add(-1 * 24 * time.Hour).UTC().Format(time.RFC3339Nano),
|
|
"hashes": map[string]string{"sha256": "abc"},
|
|
},
|
|
},
|
|
})
|
|
|
|
// parsePEP691Files will skip the .egg file (no version extracted),
|
|
// so dates will be empty — nothing to strip
|
|
dates, err := handler.parsePEP691Files(body)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, dates)
|
|
|
|
// Force tooNew to include a version that matches nothing, to exercise the
|
|
// stripCooldownFiles path with a non-empty tooNew map
|
|
forcedDates := map[string]time.Time{
|
|
"1.0.0": now.Add(-1 * 24 * time.Hour),
|
|
}
|
|
newBody, stripped, _ := handler.stripCooldownFiles(body, forcedDates, 5)
|
|
assert.Equal(t, 1, stripped) // version is "stripped" from the date map perspective
|
|
|
|
var result struct {
|
|
Files []json.RawMessage `json:"files"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
// The .egg file must still be present — unparseable filename means fail-open
|
|
assert.Len(t, result.Files, 1)
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
|
|
ctx.Headers.Set("Accept", "application/vnd.pypi.simple.v1+json, text/html;q=0.01")
|
|
ctx.Headers.Set("Accept-Encoding", "gzip")
|
|
ctx.Headers.Set("If-None-Match", `"abc123"`)
|
|
ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT")
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
|
assert.Equal(t, "application/vnd.pypi.simple.v1+json", ctx.Headers.Get("Accept"))
|
|
assert.Equal(t, "identity", ctx.Headers.Get("Accept-Encoding"))
|
|
assert.Empty(t, ctx.Headers.Get("If-None-Match"))
|
|
assert.Empty(t, ctx.Headers.Get("If-Modified-Since"))
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_ClientWithoutPEP691_SkipsCooldown(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
|
|
ctx.Headers.Set("Accept", "text/html")
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
|
assert.Nil(t, resp.ResponseModifier)
|
|
assert.Equal(t, "text/html", ctx.Headers.Get("Accept"))
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_NonJSONResponse_FailOpen(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
|
|
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
htmlBody := []byte(`<!DOCTYPE html><html><body><a href="/packages/requests-2.31.0.tar.gz">requests-2.31.0.tar.gz</a></body></html>`)
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", "text/html")
|
|
|
|
_, retHeaders, retBody, err := resp.ResponseModifier(200, headers, htmlBody)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, htmlBody, retBody)
|
|
assert.NotEqual(t, "no-store", retHeaders.Get("Cache-Control"))
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * day),
|
|
"2.0.0": now.Add(-1 * day),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
handler := newPypiCooldownHandler(NewAnalysisStatsCollector())
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
|
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
|
|
|
|
_, retHeaders, retBody, err := resp.ResponseModifier(200, headers, body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "no-store", retHeaders.Get("Cache-Control"))
|
|
|
|
var result struct {
|
|
Files []struct {
|
|
Filename string `json:"filename"`
|
|
} `json:"files"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(retBody, &result))
|
|
|
|
filenames := make([]string, 0, len(result.Files))
|
|
for _, f := range result.Files {
|
|
filenames = append(filenames, f.Filename)
|
|
}
|
|
assert.Contains(t, filenames, "testpkg-1.0.0.tar.gz")
|
|
assert.NotContains(t, filenames, "testpkg-2.0.0.tar.gz")
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t *testing.T) {
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-1 * 24 * time.Hour),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newPypiCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/newpkg/")
|
|
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, headers, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
require.Len(t, blocks, 1)
|
|
assert.Equal(t, "newpkg", blocks[0].Name)
|
|
assert.Equal(t, "1.0.0", blocks[0].Version)
|
|
assert.Equal(t, 5, blocks[0].CooldownDays)
|
|
|
|
stats := collector.GetStats()
|
|
assert.Equal(t, 1, stats.CooldownBlockedCount)
|
|
assert.Equal(t, 1, stats.BlockedCount)
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t *testing.T) {
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * day),
|
|
"2.0.0": now.Add(-20 * day),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
handler := newPypiCooldownHandler(nil)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
|
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
|
|
|
|
_, _, retBody, err := resp.ResponseModifier(200, headers, body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, body, retBody)
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T) {
|
|
handler := newPypiCooldownHandler(nil)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/badpkg/")
|
|
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
body := []byte(`not-json`)
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
|
|
|
|
_, _, retBody, err := resp.ResponseModifier(200, headers, body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, body, retBody)
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats(t *testing.T) {
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * day),
|
|
"2.0.0": now.Add(-1 * day),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newPypiCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
|
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, headers, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
require.Len(t, blocks, 1)
|
|
assert.Equal(t, "testpkg", blocks[0].Name)
|
|
assert.Equal(t, "2.0.0", blocks[0].Version)
|
|
assert.Equal(t, 5, blocks[0].CooldownDays)
|
|
|
|
stats := collector.GetStats()
|
|
assert.Equal(t, 1, stats.CooldownBlockedCount)
|
|
assert.Equal(t, 1, stats.BlockedCount)
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t *testing.T) {
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * day),
|
|
"2.0.0": now.Add(-1 * day),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newPypiCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
|
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, headers, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
assert.Empty(t, blocks)
|
|
}
|
|
|
|
func TestPyPICooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBlock(t *testing.T) {
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * day),
|
|
"2.0.0": now.Add(-1 * day),
|
|
}
|
|
body := buildTestPEP691Response(versions)
|
|
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newPypiCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
|
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
headers := http.Header{}
|
|
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, headers, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
assert.Empty(t, blocks)
|
|
}
|
|
|
|
func TestPyPICooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{})
|
|
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
|
|
ctx.Hostname = "pypi.org"
|
|
ctx.Headers.Set("Accept", "application/vnd.pypi.simple.v1+json, text/html;q=0.01")
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
|
assert.Equal(t, "application/vnd.pypi.simple.v1+json", ctx.Headers.Get("Accept"))
|
|
}
|
|
|
|
func TestPyPICooldown_InterceptorDelegation_CooldownEnabled_OldPip(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{})
|
|
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
|
|
ctx.Hostname = "pypi.org"
|
|
ctx.Headers.Set("Accept", "text/html")
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
|
assert.Equal(t, "text/html", ctx.Headers.Get("Accept"))
|
|
}
|
|
|
|
func TestPyPICooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5})
|
|
|
|
interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{})
|
|
|
|
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
|
|
ctx.Hostname = "pypi.org"
|
|
ctx.Headers.Set("Accept", "text/html")
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
|
assert.Equal(t, "text/html", ctx.Headers.Get("Accept"))
|
|
}
|
|
|
|
func TestPyPICooldown_JSONAPIRequest_NotIntercepted(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{})
|
|
|
|
ctx := makeTestRequestContext("https://pypi.org/pypi/requests/json")
|
|
ctx.Hostname = "pypi.org"
|
|
ctx.Headers.Set("Accept", "application/json")
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
|
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
|
|
}
|
|
|
|
func TestPyPICooldown_FileDownloadBypassesCooldown(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
origInsecure := config.Get().InsecureInstallation
|
|
config.Get().InsecureInstallation = true
|
|
t.Cleanup(func() { config.Get().InsecureInstallation = origInsecure })
|
|
|
|
interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{})
|
|
|
|
ctx := makeTestRequestContext("https://files.pythonhosted.org/packages/ab/cd/ef/requests-2.31.0-py3-none-any.whl")
|
|
ctx.Hostname = "files.pythonhosted.org"
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, proxy.ActionModifyResponse, resp.Action)
|
|
assert.NotEqual(t, "application/vnd.pypi.simple.v1+json", ctx.Headers.Get("Accept"))
|
|
}
|