6cf84c4f6c
Remotes store `quarantine_enabled`/`quarantine_days` but the proxy never enforces them, so freshly published (possibly malicious) artifacts are cached immediately. - refuse uncached immutable paths published under `quarantine_days` ago with a JSON 403 - take publish time from `Last-Modified` capped at now, else a Redis first-seen key; 503 + `Retry-After` when that lookup fails - reject enabled quarantine with `quarantine_days<1`; backfill stored `<=0` to 3 - default `quarantine_days` to 3 on create and keep it on update - let goproxy virtual probes skip a quarantining member Reviewed-on: #170 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
187 lines
5.9 KiB
Go
187 lines
5.9 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/cache"
|
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
goredis "github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
func must[T any](v T, err error) T {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func quarantineUpstream(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/fresh.tgz":
|
|
w.Header().Set("Last-Modified", time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat))
|
|
case "/old.tgz":
|
|
w.Header().Set("Last-Modified", time.Now().Add(-30*24*time.Hour).UTC().Format(http.TimeFormat))
|
|
case "/future.tgz":
|
|
w.Header().Set("Last-Modified", time.Now().Add(30*24*time.Hour).UTC().Format(http.TimeFormat))
|
|
case "/undated.tgz", "/pkg":
|
|
default:
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte("body " + r.URL.Path))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func quarantineRemote(t *testing.T, name string, enabled bool) models.Remote {
|
|
r := models.Remote{Name: name, PackageType: models.PackageNPM, RepoType: models.RepoTypeRemote,
|
|
BaseURL: quarantineUpstream(t).URL, QuarantineEnabled: enabled, QuarantineDays: 3}
|
|
return seed(t, r)
|
|
}
|
|
|
|
func wantQuarantined(t *testing.T, err error) *ProxyError {
|
|
t.Helper()
|
|
var pe *ProxyError
|
|
if !errors.As(err, &pe) || pe.Status != http.StatusForbidden || !Quarantined(err) {
|
|
t.Fatalf("want quarantine 403, got %v", err)
|
|
}
|
|
return pe
|
|
}
|
|
|
|
func TestQuarantineBlocksFreshArtifact(t *testing.T) {
|
|
requireStack(t)
|
|
ctx := context.Background()
|
|
r := quarantineRemote(t, "q-fresh", true)
|
|
pe := wantQuarantined(t, func() error { _, err := testEngine.Fetch(ctx, r, "fresh.tgz", prov(t, models.PackageNPM)); return err }())
|
|
if d := time.Until(pe.Available); d < 70*time.Hour || d > 72*time.Hour {
|
|
t.Errorf("available in %s, want ~71h", d)
|
|
}
|
|
if a, _ := testDB.GetArtifact(ctx, r.Name, "fresh.tgz"); a != nil {
|
|
t.Error("quarantined artifact was cached")
|
|
}
|
|
_, err := testEngine.Head(ctx, r, "fresh.tgz", prov(t, models.PackageNPM))
|
|
_ = wantQuarantined(t, err)
|
|
}
|
|
|
|
func TestQuarantineServesOldArtifact(t *testing.T) {
|
|
requireStack(t)
|
|
r := quarantineRemote(t, "q-old", true)
|
|
res, err := testEngine.Fetch(context.Background(), r, "old.tgz", prov(t, models.PackageNPM))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := readAll(t, res); got != "body /old.tgz" {
|
|
t.Errorf("body = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestQuarantineFallsBackToFirstSeen(t *testing.T) {
|
|
requireStack(t)
|
|
ctx := context.Background()
|
|
r := quarantineRemote(t, "q-undated", true)
|
|
p := prov(t, models.PackageNPM)
|
|
_, err := testEngine.Fetch(ctx, r, "undated.tgz", p)
|
|
first := wantQuarantined(t, err).Published
|
|
if time.Since(first) > time.Minute {
|
|
t.Fatalf("first seen %s, want now", first)
|
|
}
|
|
_, err = testEngine.Fetch(ctx, r, "undated.tgz", p)
|
|
if again := wantQuarantined(t, err).Published; !again.Equal(first) {
|
|
t.Errorf("first seen moved: %s -> %s", first, again)
|
|
}
|
|
|
|
r.QuarantineDays = 1
|
|
c := goredis.NewClient(must(goredis.ParseURL(testRedis)))
|
|
defer func() { _ = c.Close() }()
|
|
if err := c.Set(ctx, "firstseen:q-undated:undated.tgz", time.Now().Add(-48*time.Hour).Unix(), time.Hour).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := testEngine.Fetch(ctx, r, "undated.tgz", p); err != nil {
|
|
t.Errorf("seen 48h ago with 1 day quarantine: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQuarantineSkipsMutable(t *testing.T) {
|
|
requireStack(t)
|
|
r := quarantineRemote(t, "q-mutable", true)
|
|
if _, err := testEngine.Fetch(context.Background(), r, "pkg", prov(t, models.PackageNPM)); err != nil {
|
|
t.Errorf("mutable index blocked: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQuarantineDisabled(t *testing.T) {
|
|
requireStack(t)
|
|
r := quarantineRemote(t, "q-off", false)
|
|
if _, err := testEngine.Fetch(context.Background(), r, "fresh.tgz", prov(t, models.PackageNPM)); err != nil {
|
|
t.Errorf("disabled quarantine blocked: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQuarantineKeepsServingCached(t *testing.T) {
|
|
requireStack(t)
|
|
ctx := context.Background()
|
|
r := quarantineRemote(t, "q-cached", false)
|
|
p := prov(t, models.PackageNPM)
|
|
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.QuarantineEnabled = true
|
|
expire(t, r.Name, "fresh.tgz")
|
|
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err != nil {
|
|
t.Errorf("artifact cached before quarantine refused on refetch: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQuarantineCapsFutureLastModified(t *testing.T) {
|
|
requireStack(t)
|
|
r := quarantineRemote(t, "q-future", true)
|
|
_, err := testEngine.Fetch(context.Background(), r, "future.tgz", prov(t, models.PackageNPM))
|
|
pe := wantQuarantined(t, err)
|
|
if d := time.Until(pe.Available); d < 71*time.Hour || d > 72*time.Hour {
|
|
t.Errorf("available in %s, want ~72h", d)
|
|
}
|
|
}
|
|
|
|
func TestQuarantineRedisErrorUnavailable(t *testing.T) {
|
|
requireStack(t)
|
|
ctx := context.Background()
|
|
r := quarantineRemote(t, "q-redis-down", true)
|
|
r.StaleOnError = true
|
|
down := must(cache.NewRedis(testRedis))
|
|
_ = down.Close()
|
|
eng := NewEngine(testDB, down, testEngine.store)
|
|
_, err := eng.Fetch(ctx, r, "undated.tgz", prov(t, models.PackageNPM))
|
|
var pe *ProxyError
|
|
if !errors.As(err, &pe) || pe.Status != http.StatusServiceUnavailable || pe.RetryAfter == 0 || Quarantined(err) {
|
|
t.Fatalf("want 503 with Retry-After, got %#v", err)
|
|
}
|
|
if a, _ := testDB.GetArtifact(ctx, r.Name, "undated.tgz"); a != nil {
|
|
t.Error("artifact cached while quarantine check unavailable")
|
|
}
|
|
}
|
|
|
|
func TestQuarantineStaleOnErrorServesNothingUncached(t *testing.T) {
|
|
requireStack(t)
|
|
ctx := context.Background()
|
|
r := quarantineRemote(t, "q-stale", true)
|
|
r.StaleOnError = true
|
|
p := prov(t, models.PackageNPM)
|
|
_, err := testEngine.Fetch(ctx, r, "fresh.tgz", p)
|
|
_ = wantQuarantined(t, err)
|
|
r.BaseURL = "http://127.0.0.1:1"
|
|
if _, err := testEngine.Fetch(ctx, r, "fresh.tgz", p); err == nil {
|
|
t.Error("stale_on_error served an uncached quarantined path")
|
|
}
|
|
if a, _ := testDB.GetArtifact(ctx, r.Name, "fresh.tgz"); a != nil {
|
|
t.Error("quarantined artifact was cached")
|
|
}
|
|
}
|