mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
401 lines
13 KiB
Go
401 lines
13 KiB
Go
package interceptors
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/pmg/config"
|
|
"github.com/safedep/pmg/proxy"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// setCooldownConfig sets the global cooldown config for the duration of a test
|
|
// and restores the original value via t.Cleanup.
|
|
func setCooldownConfig(t *testing.T, cfg config.DependencyCooldownConfig) {
|
|
t.Helper()
|
|
orig := config.Get().Config.DependencyCooldown
|
|
t.Cleanup(func() { config.Get().Config.DependencyCooldown = orig })
|
|
config.Get().Config.DependencyCooldown = cfg
|
|
}
|
|
|
|
// TestParseNpmMetadataTime verifies that publish dates are correctly extracted from
|
|
// the "time" field of NPM package metadata, with skip keys omitted.
|
|
func TestParseNpmMetadataTime(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
expectCount int
|
|
expectErr bool
|
|
}{
|
|
{
|
|
name: "valid metadata with multiple versions",
|
|
body: `{
|
|
"name": "lodash",
|
|
"time": {
|
|
"created": "2012-04-23T16:52:34.248Z",
|
|
"modified": "2024-01-15T10:30:00.000Z",
|
|
"1.0.0": "2012-04-23T16:52:34.248Z",
|
|
"2.0.0": "2013-05-10T12:00:00.000Z",
|
|
"4.17.21": "2021-02-20T15:42:16.000Z"
|
|
}
|
|
}`,
|
|
expectCount: 3,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "metadata without time field",
|
|
body: `{"name": "lodash"}`,
|
|
expectCount: 0,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "only skip keys present in time",
|
|
body: `{
|
|
"time": {
|
|
"created": "2012-04-23T16:52:34.248Z",
|
|
"modified": "2024-01-15T10:30:00.000Z"
|
|
}
|
|
}`,
|
|
expectCount: 0,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "invalid JSON body",
|
|
body: `not valid json`,
|
|
expectCount: 0,
|
|
expectErr: true,
|
|
},
|
|
{
|
|
name: "unparseable date values are skipped",
|
|
body: `{
|
|
"time": {
|
|
"1.0.0": "2024-01-15T10:30:00.000Z",
|
|
"2.0.0": "not-a-date-at-all"
|
|
}
|
|
}`,
|
|
expectCount: 1,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "RFC3339 format without milliseconds is accepted",
|
|
body: `{
|
|
"time": {
|
|
"1.0.0": "2024-01-15T10:30:00Z"
|
|
}
|
|
}`,
|
|
expectCount: 1,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "millisecond precision format is accepted",
|
|
body: `{
|
|
"time": {
|
|
"1.0.0": "2024-01-15T10:30:00.000Z"
|
|
}
|
|
}`,
|
|
expectCount: 1,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "empty body",
|
|
body: ``,
|
|
expectCount: 0,
|
|
expectErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dates, err := parseNpmMetadataTime([]byte(tt.body))
|
|
if tt.expectErr {
|
|
assert.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.Len(t, dates, tt.expectCount)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseNpmMetadataTime_CorrectDates verifies that the parsed dates match the
|
|
// exact timestamps from the metadata JSON.
|
|
func TestParseNpmMetadataTime_CorrectDates(t *testing.T) {
|
|
body := `{
|
|
"name": "lodash",
|
|
"time": {
|
|
"created": "2012-04-23T16:52:34.248Z",
|
|
"modified": "2024-01-15T10:30:00.000Z",
|
|
"4.17.20": "2021-01-07T14:24:45.000Z",
|
|
"4.17.21": "2021-02-20T15:42:16.000Z"
|
|
}
|
|
}`
|
|
|
|
dates, err := parseNpmMetadataTime([]byte(body))
|
|
require.NoError(t, err)
|
|
require.Len(t, dates, 2)
|
|
|
|
expected4_17_20, err := time.Parse("2006-01-02T15:04:05.000Z", "2021-01-07T14:24:45.000Z")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expected4_17_20, dates["4.17.20"])
|
|
|
|
expected4_17_21, err := time.Parse("2006-01-02T15:04:05.000Z", "2021-02-20T15:42:16.000Z")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expected4_17_21, dates["4.17.21"])
|
|
|
|
_, hasCreated := dates["created"]
|
|
assert.False(t, hasCreated, "created key must be skipped")
|
|
|
|
_, hasModified := dates["modified"]
|
|
assert.False(t, hasModified, "modified key must be skipped")
|
|
}
|
|
|
|
// TestNpmCooldown_BlocksRecentPackage verifies that a metadata response for a package
|
|
// with a recently published version strips that version from the response.
|
|
func TestNpmCooldown_BlocksRecentPackage(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), nil, nil)
|
|
|
|
ctx := &proxy.RequestContext{
|
|
Hostname: "registry.npmjs.org",
|
|
URL: mustParseURL("https://registry.npmjs.org/lodash"),
|
|
}
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
|
|
|
recentDate := time.Now().Add(-2 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "lodash",
|
|
"dist-tags": map[string]string{"latest": "4.18.0"},
|
|
"versions": map[string]any{"4.17.21": map[string]string{"version": "4.17.21"}, "4.18.0": map[string]string{"version": "4.18.0"}},
|
|
"time": map[string]string{"4.17.21": "2021-02-20T15:42:16.000Z", "4.18.0": recentDate},
|
|
})
|
|
|
|
_, _, outBody, err := resp.ResponseModifier(http.StatusOK, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(outBody, &result))
|
|
|
|
var versions map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(result["versions"], &versions))
|
|
assert.NotContains(t, versions, "4.18.0", "recent version should be stripped")
|
|
assert.Contains(t, versions, "4.17.21")
|
|
|
|
var distTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &distTags))
|
|
assert.Equal(t, "4.17.21", distTags["latest"], "dist-tags.latest should point to oldest eligible version")
|
|
}
|
|
|
|
// TestNpmCooldown_AllowsOldPackage verifies that a metadata response with no versions
|
|
// in the cooldown window is returned unchanged.
|
|
func TestNpmCooldown_AllowsOldPackage(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), nil, nil)
|
|
|
|
ctx := &proxy.RequestContext{
|
|
Hostname: "registry.npmjs.org",
|
|
URL: mustParseURL("https://registry.npmjs.org/lodash"),
|
|
}
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "lodash",
|
|
"dist-tags": map[string]string{"latest": "4.17.21"},
|
|
"versions": map[string]any{"4.17.21": map[string]string{"version": "4.17.21"}},
|
|
"time": map[string]string{"4.17.21": "2021-02-20T15:42:16.000Z"},
|
|
})
|
|
|
|
_, _, outBody, err := resp.ResponseModifier(http.StatusOK, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, body, outBody, "body should be unchanged when no versions are in cooldown")
|
|
}
|
|
|
|
// TestNpmCooldown_DisabledByConfig verifies that when cooldown is disabled, metadata
|
|
// requests are allowed through without modification.
|
|
func TestNpmCooldown_DisabledByConfig(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5})
|
|
|
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), nil, nil)
|
|
|
|
ctx := &proxy.RequestContext{
|
|
Hostname: "registry.npmjs.org",
|
|
URL: mustParseURL("https://registry.npmjs.org/lodash"),
|
|
}
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
// Cooldown disabled: metadata requests must be allowed through without a modifier
|
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
|
assert.Nil(t, resp.ResponseModifier)
|
|
}
|
|
|
|
// TestNpmCooldown_MetadataResponseRegistersModifier verifies that a metadata request
|
|
// when cooldown is enabled returns ActionModifyResponse with a modifier set.
|
|
func TestNpmCooldown_MetadataResponseRegistersModifier(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), nil, nil)
|
|
|
|
ctx := &proxy.RequestContext{
|
|
Hostname: "registry.npmjs.org",
|
|
URL: mustParseURL("https://registry.npmjs.org/lodash"),
|
|
}
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
|
assert.NotNil(t, resp.ResponseModifier, "response modifier must be set for metadata requests when cooldown is enabled")
|
|
}
|
|
|
|
// TestNpmCooldown_MetadataStripsRecentVersions verifies that the metadata modifier
|
|
// removes versions within the cooldown window from "versions", "time", and fixes "dist-tags".
|
|
// This is the key behavior for npm update: npm's resolver never sees the too-new versions
|
|
// and naturally falls back to the latest eligible version.
|
|
func TestNpmCooldown_MetadataStripsRecentVersions(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), nil)
|
|
|
|
ctx := &proxy.RequestContext{
|
|
Hostname: "registry.npmjs.org",
|
|
URL: mustParseURL("https://registry.npmjs.org/some-pkg"),
|
|
}
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
// Build metadata where 1.3.0 was published 1 day ago (within cooldown)
|
|
recentDate := time.Now().Add(-1 * 24 * time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
|
|
oldDate := "2024-01-15T10:30:00.000Z"
|
|
|
|
metadata := map[string]interface{}{
|
|
"name": "some-pkg",
|
|
"dist-tags": map[string]string{
|
|
"latest": "1.3.0",
|
|
},
|
|
"time": map[string]string{
|
|
"created": "2023-01-01T00:00:00.000Z",
|
|
"modified": recentDate,
|
|
"1.0.0": oldDate,
|
|
"1.2.0": "2024-06-01T00:00:00.000Z",
|
|
"1.3.0": recentDate,
|
|
},
|
|
"versions": map[string]interface{}{
|
|
"1.0.0": map[string]string{"version": "1.0.0"},
|
|
"1.2.0": map[string]string{"version": "1.2.0"},
|
|
"1.3.0": map[string]string{"version": "1.3.0"},
|
|
},
|
|
}
|
|
body, err := json.Marshal(metadata)
|
|
require.NoError(t, err)
|
|
|
|
_, _, outBody, modErr := resp.ResponseModifier(http.StatusOK, http.Header{}, body)
|
|
require.NoError(t, modErr)
|
|
|
|
// Parse the modified body
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(outBody, &result))
|
|
|
|
// 1.3.0 should be stripped from "versions"
|
|
var versions map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(result["versions"], &versions))
|
|
assert.Contains(t, versions, "1.0.0")
|
|
assert.Contains(t, versions, "1.2.0")
|
|
assert.NotContains(t, versions, "1.3.0", "1.3.0 should be stripped from versions")
|
|
|
|
// 1.3.0 should be stripped from "time"
|
|
var timeMap map[string]string
|
|
require.NoError(t, json.Unmarshal(result["time"], &timeMap))
|
|
assert.Contains(t, timeMap, "1.0.0")
|
|
assert.Contains(t, timeMap, "1.2.0")
|
|
assert.NotContains(t, timeMap, "1.3.0", "1.3.0 should be stripped from time")
|
|
assert.Contains(t, timeMap, "created", "created should be preserved")
|
|
|
|
// dist-tags.latest should be updated to 1.2.0 (latest non-cooldown version)
|
|
var distTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &distTags))
|
|
assert.Equal(t, "1.2.0", distTags["latest"], "dist-tags.latest should point to latest non-cooldown version")
|
|
|
|
}
|
|
|
|
// TestStripCooldownVersions_AllVersionsTooNew verifies behavior when every version
|
|
// is within the cooldown window — all versions are stripped.
|
|
func TestStripCooldownVersions_AllVersionsTooNew(t *testing.T) {
|
|
now := time.Now()
|
|
dates := map[string]time.Time{
|
|
"1.0.0": now.Add(-1 * 24 * time.Hour),
|
|
"1.1.0": now.Add(-2 * 24 * time.Hour),
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"name": "new-pkg",
|
|
"dist-tags": map[string]string{
|
|
"latest": "1.1.0",
|
|
},
|
|
"versions": map[string]interface{}{
|
|
"1.0.0": map[string]string{"version": "1.0.0"},
|
|
"1.1.0": map[string]string{"version": "1.1.0"},
|
|
},
|
|
"time": map[string]string{
|
|
"1.0.0": now.Add(-1 * 24 * time.Hour).Format(time.RFC3339),
|
|
"1.1.0": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
})
|
|
|
|
result, stripped, remaining := stripCooldownVersions(body, dates, 5)
|
|
assert.Equal(t, 2, stripped)
|
|
assert.Equal(t, 0, remaining)
|
|
|
|
var parsed map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(result, &parsed))
|
|
|
|
var versions map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(parsed["versions"], &versions))
|
|
assert.Empty(t, versions, "all versions should be stripped")
|
|
|
|
// dist-tags should be empty since no eligible version exists
|
|
var distTags map[string]string
|
|
require.NoError(t, json.Unmarshal(parsed["dist-tags"], &distTags))
|
|
assert.Empty(t, distTags)
|
|
}
|
|
|
|
// TestStripCooldownVersions_NoVersionsTooNew verifies that when no versions are
|
|
// within the cooldown window, the body is returned unchanged.
|
|
func TestStripCooldownVersions_NoVersionsTooNew(t *testing.T) {
|
|
dates := map[string]time.Time{
|
|
"1.0.0": time.Now().Add(-30 * 24 * time.Hour),
|
|
"1.1.0": time.Now().Add(-20 * 24 * time.Hour),
|
|
}
|
|
|
|
body := []byte(`{"name":"pkg","versions":{"1.0.0":{},"1.1.0":{}},"time":{"1.0.0":"2024-01-01T00:00:00Z","1.1.0":"2024-02-01T00:00:00Z"}}`)
|
|
|
|
result, stripped, remaining := stripCooldownVersions(body, dates, 5)
|
|
assert.Equal(t, 0, stripped)
|
|
assert.Equal(t, 2, remaining)
|
|
assert.Equal(t, body, result, "body should be unchanged when no versions are in cooldown")
|
|
}
|
|
|
|
|
|
func mustParseURL(rawURL string) *url.URL {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
log.Errorf("mustParseURL: %s" + err.Error())
|
|
return nil
|
|
}
|
|
|
|
return u
|
|
}
|