test: Add proxy e2e test (#348)

* test: Add proxy e2e test

* fix: Code review fixes

* test: Add dependency cooldown skip list test case
This commit is contained in:
Abhisek Datta
2026-06-23 22:21:57 +05:30
committed by GitHub
parent d360e75897
commit 25dd12d7a4
10 changed files with 1326 additions and 13 deletions
+10
View File
@@ -22,6 +22,16 @@ go test ./config/ -v -count=1 # Run specific package tests
- `analyzer/` — Package security analysis
- `internal/` — Internal utilities (analytics, eventlog, flows, ui)
## Proxy E2E Tests
- `test/proxye2e/` is a hermetic (no network) table-driven framework for the proxy flow. It
runs the real proxy, interceptors, cooldown handlers and analyzer verdict-mapping against an
in-process mock registry and a stub malysis gRPC client.
- Any security-sensitive change to the proxy flow (interceptors, cooldown, malware
allow/confirm/block, trusted/insecure bypass, new controls) MUST add or extend an E2E case
in `test/proxye2e/`. Add a `TestCase` with `Config`/`Setup`/`Exec`/`Assert`; do not build new
scaffolding.
## Code Style
- Keep things short and simple
+19 -7
View File
@@ -49,9 +49,24 @@ func NewMalysisQueryAnalyzer(config MalysisQueryAnalyzerConfig) (*malysisQueryAn
return nil, fmt.Errorf("failed to create gRPC client: %w", err)
}
return NewMalysisQueryAnalyzerWithClient(malysisv1grpc.NewMalwareAnalysisServiceClient(client), config, false)
}
// NewMalysisQueryAnalyzerWithClient builds an analyzer over a caller-supplied
// gRPC client. It is the shared constructor behind the community and
// authenticated variants, and the injection seam used by tests to drive the
// real verdict-mapping path over a stub client. honorExclusions mirrors the
// authenticated analyzer's tenant-exclusion behavior.
func NewMalysisQueryAnalyzerWithClient(client malysisv1grpc.MalwareAnalysisServiceClient,
config MalysisQueryAnalyzerConfig, honorExclusions bool) (*malysisQueryAnalyzer, error) {
if client == nil {
return nil, fmt.Errorf("malysis client must not be nil")
}
return &malysisQueryAnalyzer{
client: malysisv1grpc.NewMalwareAnalysisServiceClient(client),
Config: config,
client: client,
Config: config,
honorExclusions: honorExclusions,
}, nil
}
@@ -67,11 +82,8 @@ func NewMalysisAuthenticatedQueryAnalyzer(config MalysisQueryAnalyzerConfig,
return nil, fmt.Errorf("failed to create authenticated gRPC client: %w", err)
}
return &malysisQueryAnalyzer{
client: malysisv1grpc.NewMalwareAnalysisServiceClient(cloudClient.Connection()),
Config: config,
honorExclusions: true,
}, nil
return NewMalysisQueryAnalyzerWithClient(
malysisv1grpc.NewMalwareAnalysisServiceClient(cloudClient.Connection()), config, true)
}
func (a *malysisQueryAnalyzer) Name() string {
+33 -6
View File
@@ -82,6 +82,17 @@ type ProxyConfig struct {
// UpstreamRetries bounds retries of idempotent upstream requests on
// transient round-trip failures. Zero disables retries.
UpstreamRetries int
// UpstreamDialContext, when set, replaces the default dialer for all upstream
// connections — both MITM'd round-trips and the CONNECT tunnels used for
// non-MITM hosts. Tests use it to redirect every hostname to a mock server so
// no path reaches the network; production leaves it nil.
UpstreamDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
// UpstreamTLSClientConfig, when set, overrides the TLS config used for
// upstream connections (e.g. to trust a mock registry's certificate). nil
// keeps the production default.
UpstreamTLSClientConfig *tls.Config
}
// DefaultProxyConfig returns a configuration with sensible defaults
@@ -144,8 +155,14 @@ func NewProxyServer(config *ProxyConfig) (ProxyServer, error) {
// the output is actually wanted.
proxy.Verbose = strings.EqualFold(os.Getenv("APP_LOG_LEVEL"), "debug")
// Configure connection timeout for upstream connections during CONNECT requests
// Configure connection timeout for upstream connections during CONNECT requests.
// A custom UpstreamDialContext also governs CONNECT tunnels so non-MITM hosts
// are dialed through the same override (tests rely on this for hermeticity).
proxy.ConnectDial = func(network, addr string) (net.Conn, error) {
if config.UpstreamDialContext != nil {
return config.UpstreamDialContext(context.Background(), network, addr)
}
dialer := &net.Dialer{
Timeout: config.ConnectTimeout,
}
@@ -192,6 +209,19 @@ func newUpstreamTransport(config *ProxyConfig) *http.Transport {
Timeout: config.ConnectTimeout,
}
dialContext := dialer.DialContext
if config.UpstreamDialContext != nil {
dialContext = config.UpstreamDialContext
}
tlsClientConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: false,
}
if config.UpstreamTLSClientConfig != nil {
tlsClientConfig = config.UpstreamTLSClientConfig
}
// Proxy honours the environment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) so
// that PMG works in enterprise environments that require a corporate
// upstream proxy to reach the internet. Loopback addresses are always
@@ -213,7 +243,7 @@ func newUpstreamTransport(config *ProxyConfig) *http.Transport {
// connection reuse.
return &http.Transport{
Proxy: proxyWithLoopbackBypass,
DialContext: dialer.DialContext,
DialContext: dialContext,
ForceAttemptHTTP2: true,
MaxConnsPerHost: 100,
MaxIdleConns: 200,
@@ -221,10 +251,7 @@ func newUpstreamTransport(config *ProxyConfig) *http.Transport {
IdleConnTimeout: 120 * time.Second,
TLSHandshakeTimeout: config.ConnectTimeout,
ResponseHeaderTimeout: config.RequestTimeout,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: false,
},
TLSClientConfig: tlsClientConfig,
}
}
+179
View File
@@ -0,0 +1,179 @@
package proxye2e
import (
"context"
"fmt"
"strings"
"sync"
"buf.build/gen/go/safedep/api/grpc/go/safedep/services/malysis/v1/malysisv1grpc"
malysisv1pb "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/malysis/v1"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
malysisv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/malysis/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Verdict is a programmable malysis response for a package version. It is the
// raw upstream signal: the real analyzer's verdict-mapping (suspicious→confirm,
// paranoid upgrade, verified→block, exclusion→allow) runs on top of it.
type Verdict struct {
resp *malysisv1.QueryPackageAnalysisResponse
err error
}
// Clean reports no malware (allow).
func Clean() Verdict {
return Verdict{resp: &malysisv1.QueryPackageAnalysisResponse{
Report: &malysisv1pb.Report{Inference: &malysisv1pb.Report_Inference{IsMalware: false, Summary: "no indicators"}},
VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: false},
}}
}
// Suspicious reports inference-only malware (unverified). Maps to confirm, or to
// block under paranoid mode.
func Suspicious() Verdict {
return Verdict{resp: &malysisv1.QueryPackageAnalysisResponse{
Report: &malysisv1pb.Report{Inference: &malysisv1pb.Report_Inference{IsMalware: true, Summary: "suspicious patterns detected"}},
VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: false},
}}
}
// VerifiedMalware reports verified malware (always block).
func VerifiedMalware() Verdict {
return Verdict{resp: &malysisv1.QueryPackageAnalysisResponse{
Report: &malysisv1pb.Report{Inference: &malysisv1pb.Report_Inference{IsMalware: true, Summary: "verified malware"}},
VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: true},
}}
}
// Excluded reports verified malware carrying a tenant exclusion. With an
// exclusion-honoring analyzer it downgrades to allow.
func Excluded(reason string) Verdict {
v := VerifiedMalware()
v.resp.MaliciousPackageExclusion = &malysisv1.QueryPackageAnalysisResponse_MaliciousPackageExclusion{
ExclusionId: "e2e-exclusion",
Reason: reason,
}
return v
}
// NotFound reports the package is absent from the analysis DB (treated as allow,
// not a failure).
func NotFound() Verdict {
return Verdict{err: status.Error(codes.NotFound, "package not found")}
}
// ServerError reports an upstream failure, exercising the fail-open path.
func ServerError() Verdict {
return Verdict{err: status.Error(codes.Unavailable, "analysis service unavailable")}
}
type AnalyzedPackage struct {
Ecosystem packagev1.Ecosystem
Name string
Version string
}
// AnalyzerRecorder holds programmable verdicts and records every query the real
// analyzer issues to the stub gRPC client.
type AnalyzerRecorder struct {
mu sync.Mutex
verdicts map[string]Verdict
calls []AnalyzedPackage
}
func newAnalyzerRecorder() *AnalyzerRecorder {
return &AnalyzerRecorder{verdicts: map[string]Verdict{}}
}
func verdictKey(eco packagev1.Ecosystem, name, version string) string {
if eco == packagev1.Ecosystem_ECOSYSTEM_PYPI {
name = normalizePypiName(name)
}
return fmt.Sprintf("%s|%s|%s", eco.String(), name, version)
}
func (r *AnalyzerRecorder) SetNpm(name, version string, v Verdict) {
r.set(packagev1.Ecosystem_ECOSYSTEM_NPM, name, version, v)
}
func (r *AnalyzerRecorder) SetPypi(name, version string, v Verdict) {
r.set(packagev1.Ecosystem_ECOSYSTEM_PYPI, name, version, v)
}
func (r *AnalyzerRecorder) set(eco packagev1.Ecosystem, name, version string, v Verdict) {
r.mu.Lock()
defer r.mu.Unlock()
r.verdicts[verdictKey(eco, name, version)] = v
}
// Calls returns every package the analyzer was queried for, in order.
func (r *AnalyzerRecorder) Calls() []AnalyzedPackage {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]AnalyzedPackage, len(r.calls))
copy(out, r.calls)
return out
}
// AnalyzedCount reports how many times a specific package version was queried.
func (r *AnalyzerRecorder) AnalyzedCount(name, version string) int {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for _, c := range r.calls {
if c.Name == name && c.Version == version {
n++
}
}
return n
}
func (r *AnalyzerRecorder) handle(req *malysisv1.QueryPackageAnalysisRequest) (*malysisv1.QueryPackageAnalysisResponse, error) {
pv := req.GetTarget().GetPackageVersion()
eco := pv.GetPackage().GetEcosystem()
name := pv.GetPackage().GetName()
version := pv.GetVersion()
r.mu.Lock()
r.calls = append(r.calls, AnalyzedPackage{Ecosystem: eco, Name: name, Version: version})
v, ok := r.verdicts[verdictKey(eco, name, version)]
r.mu.Unlock()
if !ok {
v = Clean()
}
if v.err != nil {
return nil, v.err
}
resp := v.resp
if resp.GetAnalysisId() == "" {
resp.AnalysisId = fmt.Sprintf("e2e-%s-%s", name, version)
}
return resp, nil
}
// stubAnalyzerClient implements the malysis gRPC client by delegating to the
// recorder. The embedded interface satisfies the full method set; only
// QueryPackageAnalysis is exercised by the analyzer.
type stubAnalyzerClient struct {
malysisv1grpc.MalwareAnalysisServiceClient
rec *AnalyzerRecorder
}
func (s *stubAnalyzerClient) QueryPackageAnalysis(_ context.Context,
req *malysisv1.QueryPackageAnalysisRequest, _ ...grpc.CallOption) (*malysisv1.QueryPackageAnalysisResponse, error) {
return s.rec.handle(req)
}
// normalizePypiName mirrors the interceptor's PyPI name canonicalization so
// programmed verdicts key match the name the analyzer is queried with.
func normalizePypiName(name string) string {
name = strings.ToLower(name)
name = strings.ReplaceAll(name, "_", "-")
name = strings.ReplaceAll(name, ".", "-")
return name
}
+65
View File
@@ -0,0 +1,65 @@
package proxye2e
import (
"sync"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
)
// ConfirmController drives the suspicious-package confirmation prompt. The
// default policy denies, matching PMG's safe default for an unhandled prompt.
type ConfirmController struct {
mu sync.Mutex
policy func([]*analyzer.PackageVersionAnalysisResult) (bool, error)
prompts [][]string
}
func newConfirmController() *ConfirmController {
return &ConfirmController{policy: func([]*analyzer.PackageVersionAnalysisResult) (bool, error) { return false, nil }}
}
func (c *ConfirmController) AutoApprove() {
c.setPolicy(func([]*analyzer.PackageVersionAnalysisResult) (bool, error) { return true, nil })
}
func (c *ConfirmController) AutoDeny() {
c.setPolicy(func([]*analyzer.PackageVersionAnalysisResult) (bool, error) { return false, nil })
}
func (c *ConfirmController) Func(fn func([]*analyzer.PackageVersionAnalysisResult) (bool, error)) {
c.setPolicy(fn)
}
func (c *ConfirmController) setPolicy(fn func([]*analyzer.PackageVersionAnalysisResult) (bool, error)) {
c.mu.Lock()
defer c.mu.Unlock()
c.policy = fn
}
// Prompts returns the package names presented for confirmation, one entry per prompt.
func (c *ConfirmController) Prompts() [][]string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([][]string, len(c.prompts))
copy(out, c.prompts)
return out
}
func (c *ConfirmController) interaction() *guard.PackageManagerGuardInteraction {
return &guard.PackageManagerGuardInteraction{
GetConfirmationOnMalware: func(pkgs []*analyzer.PackageVersionAnalysisResult) (bool, error) {
names := make([]string, 0, len(pkgs))
for _, p := range pkgs {
names = append(names, p.PackageVersion.GetPackage().GetName())
}
c.mu.Lock()
c.prompts = append(c.prompts, names)
policy := c.policy
c.mu.Unlock()
return policy(pkgs)
},
}
}
+146
View File
@@ -0,0 +1,146 @@
package proxye2e
import (
"encoding/json"
"fmt"
"strings"
)
type RequestOutcome struct {
URL string
StatusCode int
Blocked bool
Body string
Err error
}
// ExecResult is the aggregate of requests an install driver issued.
type ExecResult struct {
Requests []RequestOutcome
}
func (e *ExecResult) add(o RequestOutcome) { e.Requests = append(e.Requests, o) }
// Blocked reports whether any request was blocked by the proxy.
func (e ExecResult) Blocked() bool {
for _, r := range e.Requests {
if r.Blocked {
return true
}
}
return false
}
type NpmDriver struct{ h *Harness }
type NpmMetadata struct {
Outcome RequestOutcome
DistTags map[string]string `json:"dist-tags"`
Versions map[string]json.RawMessage `json:"versions"`
Time map[string]string `json:"time"`
}
func (m NpmMetadata) HasVersion(v string) bool {
_, ok := m.Versions[v]
return ok
}
func (d NpmDriver) FetchMetadata(name string) NpmMetadata {
out := d.h.get(fmt.Sprintf("https://registry.npmjs.org/%s", name), nil)
meta := NpmMetadata{Outcome: out}
if out.Err == nil && out.StatusCode == 200 {
if err := json.Unmarshal([]byte(out.Body), &meta); err != nil {
meta.Outcome.Err = fmt.Errorf("failed to decode npm metadata for %s: %w", name, err)
}
}
return meta
}
func (d NpmDriver) Download(name, version string) RequestOutcome {
return d.h.get(fmt.Sprintf("https://registry.npmjs.org/%s/-/%s-%s.tgz", name, name, version), nil)
}
// Install replays npm's resolve-then-download sequence: fetch the packument,
// pick the requested version (or dist-tags.latest), and download it only if it
// survived in the metadata the proxy returned.
func (d NpmDriver) Install(name, version string) ExecResult {
res := ExecResult{}
meta := d.FetchMetadata(name)
res.add(meta.Outcome)
target := version
if target == "" {
target = meta.DistTags["latest"]
}
if target != "" && meta.HasVersion(target) {
res.add(d.Download(name, target))
}
return res
}
type PypiDriver struct{ h *Harness }
type PypiSimpleFile struct {
Filename string `json:"filename"`
URL string `json:"url"`
UploadTime string `json:"upload-time"`
}
type PypiSimple struct {
Outcome RequestOutcome
Files []PypiSimpleFile `json:"files"`
}
func (s PypiSimple) fileForVersion(name, version string) (PypiSimpleFile, bool) {
prefix := fmt.Sprintf("%s-%s.", normalizePypiName(name), version)
for _, f := range s.Files {
if strings.HasPrefix(f.Filename, prefix) {
return f, true
}
}
return PypiSimpleFile{}, false
}
func (s PypiSimple) HasVersion(name, version string) bool {
_, ok := s.fileForVersion(name, version)
return ok
}
func (d PypiDriver) FetchSimple(name string) PypiSimple {
out := d.h.get(
fmt.Sprintf("https://pypi.org/simple/%s/", normalizePypiName(name)),
map[string]string{"Accept": pypiSimpleContentType},
)
simple := PypiSimple{Outcome: out}
if out.Err == nil && out.StatusCode == 200 {
if err := json.Unmarshal([]byte(out.Body), &simple); err != nil {
simple.Outcome.Err = fmt.Errorf("failed to decode PyPI simple index for %s: %w", name, err)
}
}
return simple
}
func (d PypiDriver) Download(fileURL string) RequestOutcome {
return d.h.get(fileURL, nil)
}
// Install replays pip's resolve-then-download sequence over the PEP 691 Simple
// API: fetch the index, then download the requested version's file only if it
// survived cooldown stripping.
func (d PypiDriver) Install(name, version string) ExecResult {
res := ExecResult{}
simple := d.FetchSimple(name)
res.add(simple.Outcome)
if f, ok := simple.fileForVersion(name, version); ok {
res.add(d.Download(f.URL))
}
return res
}
+222
View File
@@ -0,0 +1,222 @@
package proxye2e
import (
"context"
"crypto/tls"
"crypto/x509"
"io"
"net"
"net/http"
"net/url"
"sync"
"testing"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/internal/models"
"github.com/safedep/pmg/proxy"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/proxy/interceptors"
"github.com/stretchr/testify/require"
)
// Harness wires the real proxy, interceptors and analyzer against an in-process
// mock registry and a stub malysis client. It is the single entry point a test
// case uses to register fixtures, drive traffic and assert outcomes.
type Harness struct {
t *testing.T
Registry *Registry
Analyzer *AnalyzerRecorder
Confirm *ConfirmController
stats *interceptors.AnalysisStatsCollector
proxy proxy.ProxyServer
client *http.Client
confChan chan *interceptors.ConfirmationRequest
dialMu sync.Mutex
dialedAddrs []string
}
type options struct {
pinnedVersions map[string]string
}
type Option func(*options)
// WithPinnedVersions seeds the interceptor's pinned-version context, which
// cooldown uses to report when an explicitly requested version is blocked.
func WithPinnedVersions(pinned map[string]string) Option {
return func(o *options) { o.pinnedVersions = pinned }
}
func New(t *testing.T, opts ...Option) *Harness {
t.Helper()
var o options
for _, opt := range opts {
opt(&o)
}
caCert, err := certmanager.GenerateCA(certmanager.DefaultCertManagerConfig())
require.NoError(t, err)
certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, certmanager.DefaultCertManagerConfig())
require.NoError(t, err)
registry := newRegistry()
rec := newAnalyzerRecorder()
confirm := newConfirmController()
stats := interceptors.NewAnalysisStatsCollector()
malysisAnalyzer, err := analyzer.NewMalysisQueryAnalyzerWithClient(
&stubAnalyzerClient{rec: rec}, analyzer.MalysisQueryAnalyzerConfig{}, true)
require.NoError(t, err)
confChan := make(chan *interceptors.ConfirmationRequest, 10)
go interceptors.HandleConfirmationRequests(confChan, confirm.interaction(), nil)
factory := interceptors.NewInterceptorFactory(
malysisAnalyzer,
interceptors.NewInMemoryAnalysisCache(),
stats,
confChan,
interceptors.InterceptorContext{PinnedVersions: o.pinnedVersions},
)
interceptorList := []proxy.Interceptor{interceptors.NewAuditLoggerInterceptor()}
for _, eco := range []packagev1.Ecosystem{packagev1.Ecosystem_ECOSYSTEM_NPM, packagev1.Ecosystem_ECOSYSTEM_PYPI} {
ic, ierr := factory.CreateInterceptor(eco)
require.NoError(t, ierr)
interceptorList = append(interceptorList, ic)
}
h := &Harness{
t: t,
Registry: registry,
Analyzer: rec,
Confirm: confirm,
stats: stats,
confChan: confChan,
}
h.proxy = buildProxy(t, certMgr, registry.addr(), interceptorList, h.recordDial)
caPool := x509.NewCertPool()
caPool.AddCert(caCert.X509Cert)
proxyURL, err := url.Parse("http://" + h.proxy.Address())
require.NoError(t, err)
h.client = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
TLSClientConfig: &tls.Config{RootCAs: caPool},
},
}
return h
}
func buildProxy(t *testing.T, certMgr certmanager.CertificateManager, upstreamAddr string, interceptorList []proxy.Interceptor, recordDial func(string)) proxy.ProxyServer {
t.Helper()
cfg := proxy.DefaultProxyConfig()
cfg.CertManager = certMgr
cfg.Interceptors = interceptorList
// All upstream connections — MITM'd round-trips and CONNECT tunnels for
// non-MITM hosts alike — terminate at the mock registry, so no test reaches
// the network regardless of the hostname being proxied.
cfg.UpstreamDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
recordDial(addr)
return (&net.Dialer{}).DialContext(ctx, network, upstreamAddr)
}
// Test-only: the mock's self-signed cert cannot match the real registry SNIs
// the proxy presents upstream, so verification is skipped for this in-process
// hop (the same approach proxy/scale_test.go uses).
cfg.UpstreamTLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402
server, err := proxy.NewProxyServer(cfg)
require.NoError(t, err)
require.NoError(t, server.Start())
return server
}
// Close stops the proxy first so no interceptor can send on the confirmation
// channel after it is closed.
func (h *Harness) Close() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = h.proxy.Stop(ctx)
close(h.confChan)
h.Registry.close()
}
func (h *Harness) Npm() NpmDriver { return NpmDriver{h: h} }
func (h *Harness) Pypi() PypiDriver { return PypiDriver{h: h} }
func (h *Harness) Stats() interceptors.AnalysisStats { return h.stats.GetStats() }
func (h *Harness) BlockedPackages() []*analyzer.PackageVersionAnalysisResult {
return h.stats.GetBlockedPackages()
}
func (h *Harness) CooldownBlocks() []models.CooldownBlock { return h.stats.GetCooldownBlocks() }
func (h *Harness) recordDial(addr string) {
h.dialMu.Lock()
defer h.dialMu.Unlock()
h.dialedAddrs = append(h.dialedAddrs, addr)
}
// DialedAddrs returns the upstream addresses the proxy was asked to connect to,
// before redirection to the mock. A non-MITM host appearing here proves its
// CONNECT tunnel went through the override rather than the real network.
func (h *Harness) DialedAddrs() []string {
h.dialMu.Lock()
defer h.dialMu.Unlock()
out := make([]string, len(h.dialedAddrs))
copy(out, h.dialedAddrs)
return out
}
// RawClient returns an HTTP client wired through the proxy and trusting the MITM
// CA, for edge cases the install drivers do not model.
func (h *Harness) RawClient() *http.Client { return h.client }
func (h *Harness) get(rawURL string, headers map[string]string) RequestOutcome {
h.t.Helper()
out := RequestOutcome{URL: rawURL}
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
out.Err = err
return out
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := h.client.Do(req)
if err != nil {
out.Err = err
return out
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
out.Err = err
return out
}
out.StatusCode = resp.StatusCode
out.Blocked = resp.StatusCode == http.StatusForbidden
out.Body = string(body)
return out
}
+362
View File
@@ -0,0 +1,362 @@
package proxye2e
import (
"testing"
"time"
"github.com/safedep/pmg/config"
"github.com/stretchr/testify/assert"
)
func recent() time.Time { return time.Now().Add(-24 * time.Hour) }
func old() time.Time { return time.Now().Add(-100 * 24 * time.Hour) }
func cooldownEnabled(days int) func(rc *config.RuntimeConfig) {
return func(rc *config.RuntimeConfig) {
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{Enabled: true, Days: days}
}
}
func TestProxyFlow_Npm(t *testing.T) {
RunCases(t, []TestCase{
{
Name: "clean package is analyzed and allowed",
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("left-pad", "1.0.0", Clean())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
assert.Equal(t, 1, h.Analyzer.AnalyzedCount("left-pad", "1.0.0"))
assert.True(t, h.Registry.DownloadedTarball("left-pad", "1.0.0"))
assert.GreaterOrEqual(t, h.Stats().AllowedCount, 1)
},
},
{
Name: "verified malware is blocked before download",
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "evil", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("evil", "1.0.0", VerifiedMalware())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("evil", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.True(t, res.Blocked())
assert.False(t, h.Registry.DownloadedTarball("evil", "1.0.0"))
assert.Len(t, h.BlockedPackages(), 1)
},
},
{
Name: "suspicious package blocked when user declines",
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "maybe", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("maybe", "1.0.0", Suspicious())
h.Confirm.AutoDeny()
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("maybe", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.True(t, res.Blocked())
assert.Len(t, h.Confirm.Prompts(), 1)
assert.False(t, h.Registry.DownloadedTarball("maybe", "1.0.0"))
},
},
{
Name: "suspicious package allowed when user confirms",
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "maybe", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("maybe", "1.0.0", Suspicious())
h.Confirm.AutoApprove()
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("maybe", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
assert.Len(t, h.Confirm.Prompts(), 1)
assert.True(t, h.Registry.DownloadedTarball("maybe", "1.0.0"))
assert.GreaterOrEqual(t, h.Stats().ConfirmedCount, 1)
},
},
{
Name: "paranoid mode blocks suspicious without prompting",
Config: func(rc *config.RuntimeConfig) { rc.Config.Paranoid = true },
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "maybe", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("maybe", "1.0.0", Suspicious())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("maybe", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.True(t, res.Blocked())
assert.Empty(t, h.Confirm.Prompts())
},
},
{
Name: "cooldown strips in-window version from metadata",
Config: cooldownEnabled(7),
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0", Versions: []NpmVersion{
{Version: "1.0.0", PublishedAt: old()},
{Version: "2.0.0", PublishedAt: recent()},
}})
h.Analyzer.SetNpm("left-pad", "1.0.0", Clean())
h.Analyzer.SetNpm("left-pad", "2.0.0", Clean())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
meta := h.Npm().FetchMetadata("left-pad")
assert.False(t, meta.HasVersion("2.0.0"), "in-window version must be stripped")
assert.True(t, meta.HasVersion("1.0.0"), "out-of-window version must survive")
assert.False(t, h.Registry.DownloadedTarball("left-pad", "2.0.0"))
},
},
{
Name: "cooldown records a blocked pinned version",
Config: cooldownEnabled(7),
PinnedVersions: map[string]string{"left-pad": "2.0.0"},
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0", Versions: []NpmVersion{
{Version: "1.0.0", PublishedAt: old()},
{Version: "2.0.0", PublishedAt: recent()},
}})
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.GreaterOrEqual(t, h.Stats().CooldownBlockedCount, 1)
blocks := h.CooldownBlocks()
var found bool
for _, b := range blocks {
if b.Name == "left-pad" && b.Version == "2.0.0" {
found = true
}
}
assert.True(t, found, "pinned in-window version should be recorded as a cooldown block")
},
},
{
Name: "cooldown allows out-of-window version",
Config: cooldownEnabled(7),
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("left-pad", "1.0.0", Clean())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
assert.True(t, h.Registry.DownloadedTarball("left-pad", "1.0.0"))
assert.Equal(t, 1, h.Analyzer.AnalyzedCount("left-pad", "1.0.0"))
},
},
{
Name: "cooldown skip waives wait but malware still blocks",
Config: func(rc *config.RuntimeConfig) {
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{
Enabled: true, Days: 7,
Skip: []config.TrustedPackage{{Purl: "pkg:npm/left-pad@2.0.0"}},
}
},
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0",
Versions: []NpmVersion{{Version: "2.0.0", PublishedAt: recent()}}})
h.Analyzer.SetNpm("left-pad", "2.0.0", VerifiedMalware())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
meta := h.Npm().FetchMetadata("left-pad")
assert.True(t, meta.HasVersion("2.0.0"), "skip-listed version must survive cooldown")
assert.True(t, res.Blocked(), "malware analysis still applies to a cooldown-skipped version")
assert.GreaterOrEqual(t, h.Analyzer.AnalyzedCount("left-pad", "2.0.0"), 1)
},
},
{
Name: "cooldown skip fast-tracks a clean in-window version",
Config: func(rc *config.RuntimeConfig) {
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{
Enabled: true, Days: 7,
Skip: []config.TrustedPackage{{Purl: "pkg:npm/left-pad@2.0.0"}},
}
},
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0", Versions: []NpmVersion{
{Version: "1.0.0", PublishedAt: old()},
{Version: "2.0.0", PublishedAt: recent()},
}})
h.Analyzer.SetNpm("left-pad", "2.0.0", Clean())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
meta := h.Npm().FetchMetadata("left-pad")
assert.True(t, meta.HasVersion("2.0.0"), "skip-listed in-window version must survive cooldown")
assert.True(t, h.Registry.DownloadedTarball("left-pad", "2.0.0"))
assert.GreaterOrEqual(t, h.Analyzer.AnalyzedCount("left-pad", "2.0.0"), 1, "skip waives cooldown only, not malware analysis")
},
},
{
Name: "cooldown whole-package skip keeps every version",
Config: func(rc *config.RuntimeConfig) {
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{
Enabled: true, Days: 7,
Skip: []config.TrustedPackage{{Purl: "pkg:npm/left-pad"}},
}
},
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0", Versions: []NpmVersion{
{Version: "1.0.0", PublishedAt: recent()},
{Version: "2.0.0", PublishedAt: recent()},
}})
h.Analyzer.SetNpm("left-pad", "2.0.0", Clean())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
meta := h.Npm().FetchMetadata("left-pad")
assert.True(t, meta.HasVersion("1.0.0"), "version-less skip must keep all in-window versions")
assert.True(t, meta.HasVersion("2.0.0"), "version-less skip must keep all in-window versions")
assert.True(t, h.Registry.DownloadedTarball("left-pad", "2.0.0"))
},
},
{
Name: "trusted package skips analysis entirely",
Config: func(rc *config.RuntimeConfig) {
rc.Config.TrustedPackages = []config.TrustedPackage{{Purl: "pkg:npm/left-pad"}}
},
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("left-pad", "1.0.0", VerifiedMalware())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
assert.Equal(t, 0, h.Analyzer.AnalyzedCount("left-pad", "1.0.0"))
assert.True(t, h.Registry.DownloadedTarball("left-pad", "1.0.0"))
},
},
{
Name: "trusted package waives both cooldown and malware analysis",
Config: func(rc *config.RuntimeConfig) {
rc.Config.TrustedPackages = []config.TrustedPackage{{Purl: "pkg:npm/left-pad"}}
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{Enabled: true, Days: 7}
},
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: recent()}}})
h.Analyzer.SetNpm("left-pad", "1.0.0", VerifiedMalware())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
meta := h.Npm().FetchMetadata("left-pad")
assert.True(t, meta.HasVersion("1.0.0"), "trusted package must bypass an active cooldown window")
assert.Equal(t, 0, h.Analyzer.AnalyzedCount("left-pad", "1.0.0"), "trusted package must bypass malware analysis")
assert.True(t, h.Registry.DownloadedTarball("left-pad", "1.0.0"))
},
},
{
Name: "insecure mode bypasses analysis",
Config: func(rc *config.RuntimeConfig) { rc.InsecureInstallation = true },
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "evil", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("evil", "1.0.0", VerifiedMalware())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("evil", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
assert.Equal(t, 0, h.Analyzer.AnalyzedCount("evil", "1.0.0"))
},
},
{
Name: "analyzer NotFound allows the package",
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "unknown", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("unknown", "1.0.0", NotFound())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("unknown", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
assert.True(t, h.Registry.DownloadedTarball("unknown", "1.0.0"))
},
},
{
Name: "analyzer error fails open and allows",
Setup: func(h *Harness) {
h.Registry.AddNpm(NpmPackage{Name: "flaky", DistTagLatest: "1.0.0",
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetNpm("flaky", "1.0.0", ServerError())
},
Exec: func(h *Harness) ExecResult { return h.Npm().Install("flaky", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
assert.True(t, h.Registry.DownloadedTarball("flaky", "1.0.0"))
},
},
})
}
// A host the interceptors observe but never MITM (test.pypi.org) is tunneled via
// CONNECT. The override must route that tunnel to the mock so no proxy path
// escapes to the real network.
func TestProxyFlow_NonMitmHostStaysHermetic(t *testing.T) {
applyConfig(t, nil)
h := New(t)
defer h.Close()
_, _ = h.RawClient().Get("https://test.pypi.org/simple/requests/")
assert.Contains(t, h.DialedAddrs(), "test.pypi.org:443",
"non-MITM CONNECT tunnel must be dialed through the mock override")
}
func TestProxyFlow_Pypi(t *testing.T) {
RunCases(t, []TestCase{
{
Name: "clean package is analyzed and allowed",
Setup: func(h *Harness) {
h.Registry.AddPypi(PypiPackage{Name: "requests",
Versions: []PypiVersion{{Version: "2.0.0", PublishedAt: old()}}})
h.Analyzer.SetPypi("requests", "2.0.0", Clean())
},
Exec: func(h *Harness) ExecResult { return h.Pypi().Install("requests", "2.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.False(t, res.Blocked())
assert.Equal(t, 1, h.Analyzer.AnalyzedCount("requests", "2.0.0"))
},
},
{
Name: "verified malware is blocked",
Setup: func(h *Harness) {
h.Registry.AddPypi(PypiPackage{Name: "evil",
Versions: []PypiVersion{{Version: "1.0.0", PublishedAt: old()}}})
h.Analyzer.SetPypi("evil", "1.0.0", VerifiedMalware())
},
Exec: func(h *Harness) ExecResult { return h.Pypi().Install("evil", "1.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
assert.True(t, res.Blocked())
assert.Len(t, h.BlockedPackages(), 1)
},
},
{
Name: "cooldown strips in-window version with name normalization",
Config: cooldownEnabled(7),
Setup: func(h *Harness) {
h.Registry.AddPypi(PypiPackage{Name: "Flask_Thing", Versions: []PypiVersion{
{Version: "1.0.0", PublishedAt: old()},
{Version: "2.0.0", PublishedAt: recent()},
}})
},
Exec: func(h *Harness) ExecResult { return h.Pypi().Install("Flask_Thing", "2.0.0") },
Assert: func(t *testing.T, h *Harness, res ExecResult) {
simple := h.Pypi().FetchSimple("Flask_Thing")
assert.False(t, simple.HasVersion("Flask_Thing", "2.0.0"), "in-window version must be stripped")
assert.True(t, simple.HasVersion("Flask_Thing", "1.0.0"), "out-of-window version must survive")
},
},
})
}
+220
View File
@@ -0,0 +1,220 @@
package proxye2e
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"time"
)
type NpmVersion struct {
Version string
PublishedAt time.Time
Tarball []byte
}
type NpmPackage struct {
Name string
DistTagLatest string
Versions []NpmVersion
}
type PypiVersion struct {
Version string
PublishedAt time.Time
Bytes []byte
}
type PypiPackage struct {
Name string
Versions []PypiVersion
}
type RecordedRequest struct {
Host string
Method string
Path string
}
// Registry is an in-process stand-in for the npm and PyPI registries. The proxy
// upstream is redirected here, so it answers for every registry hostname and
// records each request for routing assertions.
type Registry struct {
mu sync.Mutex
npm map[string]NpmPackage
pypi map[string]PypiPackage
requests []RecordedRequest
server *httptest.Server
}
func newRegistry() *Registry {
r := &Registry{
npm: map[string]NpmPackage{},
pypi: map[string]PypiPackage{},
}
r.server = httptest.NewTLSServer(http.HandlerFunc(r.serve))
return r
}
func (r *Registry) addr() string { return r.server.Listener.Addr().String() }
func (r *Registry) close() { r.server.Close() }
func (r *Registry) AddNpm(pkg NpmPackage) {
r.mu.Lock()
defer r.mu.Unlock()
r.npm[pkg.Name] = pkg
}
func (r *Registry) AddPypi(pkg PypiPackage) {
r.mu.Lock()
defer r.mu.Unlock()
r.pypi[normalizePypiName(pkg.Name)] = pkg
}
// Requests returns every request the proxy forwarded upstream, in order.
func (r *Registry) Requests() []RecordedRequest {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]RecordedRequest, len(r.requests))
copy(out, r.requests)
return out
}
// DownloadedTarball reports whether a tarball for the given npm package version
// was fetched from the registry.
func (r *Registry) DownloadedTarball(name, version string) bool {
want := fmt.Sprintf("/%s/-/%s-%s.tgz", name, name, version)
for _, req := range r.Requests() {
if req.Path == want {
return true
}
}
return false
}
func (r *Registry) serve(w http.ResponseWriter, req *http.Request) {
host := hostOnly(req.Host)
r.mu.Lock()
r.requests = append(r.requests, RecordedRequest{Host: host, Method: req.Method, Path: req.URL.Path})
r.mu.Unlock()
switch host {
case "registry.npmjs.org", "registry.yarnpkg.com":
r.serveNpm(w, req)
case "pypi.org":
r.servePypiSimple(w, req)
case "files.pythonhosted.org":
r.servePypiFile(w, req)
default:
http.NotFound(w, req)
}
}
func (r *Registry) serveNpm(w http.ResponseWriter, req *http.Request) {
path := strings.Trim(req.URL.Path, "/")
if strings.Contains(path, "/-/") {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte("e2e-tarball"))
return
}
r.mu.Lock()
pkg, ok := r.npm[path]
r.mu.Unlock()
if !ok {
http.NotFound(w, req)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(buildPackument(pkg))
}
func (r *Registry) servePypiSimple(w http.ResponseWriter, req *http.Request) {
name := strings.Trim(strings.TrimPrefix(req.URL.Path, "/simple/"), "/")
r.mu.Lock()
pkg, ok := r.pypi[normalizePypiName(name)]
r.mu.Unlock()
if !ok {
http.NotFound(w, req)
return
}
w.Header().Set("Content-Type", pypiSimpleContentType)
_, _ = w.Write(buildPypiSimple(pkg))
}
func (r *Registry) servePypiFile(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte("e2e-wheel"))
}
const pypiSimpleContentType = "application/vnd.pypi.simple.v1+json"
func buildPackument(pkg NpmPackage) []byte {
versions := map[string]any{}
times := map[string]string{}
for _, v := range pkg.Versions {
versions[v.Version] = map[string]any{
"name": pkg.Name,
"version": v.Version,
"dist": map[string]any{
"tarball": fmt.Sprintf("https://registry.npmjs.org/%s/-/%s-%s.tgz", pkg.Name, pkg.Name, v.Version),
},
}
times[v.Version] = v.PublishedAt.UTC().Format(time.RFC3339)
}
latest := pkg.DistTagLatest
if latest == "" && len(pkg.Versions) > 0 {
latest = pkg.Versions[len(pkg.Versions)-1].Version
}
doc := map[string]any{
"name": pkg.Name,
"dist-tags": map[string]string{"latest": latest},
"versions": versions,
"time": times,
}
body, _ := json.Marshal(doc)
return body
}
func buildPypiSimple(pkg PypiPackage) []byte {
norm := normalizePypiName(pkg.Name)
files := []map[string]any{}
for _, v := range pkg.Versions {
filename := fmt.Sprintf("%s-%s.tar.gz", norm, v.Version)
files = append(files, map[string]any{
"filename": filename,
"url": fmt.Sprintf("https://files.pythonhosted.org/packages/source/%c/%s/%s", norm[0], norm, filename),
"hashes": map[string]string{},
"upload-time": v.PublishedAt.UTC().Format(time.RFC3339Nano),
})
}
doc := map[string]any{
"meta": map[string]any{"api-version": "1.0"},
"name": norm,
"files": files,
}
body, _ := json.Marshal(doc)
return body
}
func hostOnly(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
return host
}
+70
View File
@@ -0,0 +1,70 @@
package proxye2e
import (
"testing"
"github.com/safedep/pmg/config"
)
// TestCase is one end-to-end scenario. Config mutates the global PMG config for
// the case; Setup registers fixtures and verdicts; Exec drives traffic; Assert
// verifies the outcome.
type TestCase struct {
Name string
PinnedVersions map[string]string
Config func(rc *config.RuntimeConfig)
Setup func(h *Harness)
Exec func(h *Harness) ExecResult
Assert func(t *testing.T, h *Harness, result ExecResult)
}
// RunCases runs each case serially. Serial execution is required because the
// interceptors read the global config singleton at request time, which the
// runner mutates per case.
func RunCases(t *testing.T, cases []TestCase) {
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
applyConfig(t, tc.Config)
h := New(t, WithPinnedVersions(tc.PinnedVersions))
defer h.Close()
if tc.Setup != nil {
tc.Setup(h)
}
var result ExecResult
if tc.Exec != nil {
result = tc.Exec(h)
}
if tc.Assert != nil {
tc.Assert(t, h, result)
}
})
}
}
// applyConfig resets the security-relevant config fields to a known hermetic
// baseline, applies the case override, then restores the original on cleanup so
// a developer's on-disk config never leaks into a case.
func applyConfig(t *testing.T, override func(rc *config.RuntimeConfig)) {
t.Helper()
rc := config.Get()
saved := *rc
t.Cleanup(func() { *rc = saved })
rc.InsecureInstallation = false
rc.Config.Paranoid = false
rc.Config.TrustedPackages = nil
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{}
if override != nil {
override(rc)
}
if err := config.PreprocessTrustedPackages(&rc.Config); err != nil {
t.Fatalf("failed to preprocess trusted packages: %v", err)
}
}