Root cause of the flaky cache tests (TestServeHTTP_DirectoryListingsCachedAsSidecar and the other hit-path tests, ~1-in-many under parallel load): on a cache hit, ServeHTTP launches `go c.revalidate(...)` / `go c.revalidateListing(...)`, which write into the cache root (MkdirAll + CreateTemp + Rename). Those goroutines outlive the request — and in tests, the test — so they race t.TempDir's RemoveAll cleanup, recreating the dir or dropping a temp file mid-removal. testing then reports "TempDir RemoveAll cleanup: ... directory not empty" and marks the test failed (with a 0.00s body, no assertion line). It only surfaced under the full parallel suite / -count because the timing has to collide. Fix: track these background goroutines in a sync.WaitGroup via a goBackground helper, and expose Wait(). newTestCache registers t.Cleanup(c.Wait) — cleanups fire LIFO and t.TempDir registered its RemoveAll first, so the drain runs before it (upstream Close was registered earliest, so it runs last and stays up while goroutines finish). runClient also calls cacheLayer.Wait() after srv.Shutdown so in-flight sidecar writes complete on graceful shutdown rather than being abandoned. Verified: cache package at -count=200 reliably failed before, passes clean after (0 failures, 0 cleanup errors); full `go test ./...` + vet green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
629 lines
20 KiB
Go
629 lines
20 KiB
Go
package cache
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
)
|
|
|
|
// newTestCache spins up an httptest server as the upstream and
|
|
// returns the cache + the upstream's URL. The upstream's behavior is
|
|
// the caller's to define.
|
|
func newTestCache(t *testing.T, mode string, upstreamHandler http.HandlerFunc) (*Cache, *httptest.Server) {
|
|
t.Helper()
|
|
upstream := httptest.NewServer(upstreamHandler)
|
|
t.Cleanup(upstream.Close)
|
|
root := t.TempDir()
|
|
c, err := New(config.Config{
|
|
Root: root,
|
|
Upstream: upstream.URL,
|
|
Mode: mode,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
// Drain background revalidation goroutines before the test's
|
|
// t.TempDir cleanup runs. Cleanups fire LIFO and t.TempDir
|
|
// registered its RemoveAll first (at the t.TempDir() call above),
|
|
// so this runs before it — preventing a revalidate goroutine from
|
|
// recreating the cache dir / dropping a temp file mid-RemoveAll
|
|
// ("directory not empty"). The upstream stays up (its Close was
|
|
// registered earliest, so it runs last).
|
|
t.Cleanup(c.Wait)
|
|
return c, upstream
|
|
}
|
|
|
|
func TestNew_RequiresUpstream(t *testing.T) {
|
|
if _, err := New(config.Config{Root: t.TempDir()}); err == nil {
|
|
t.Error("expected error for empty upstream")
|
|
}
|
|
}
|
|
|
|
func TestNew_StripsTrailingSlash(t *testing.T) {
|
|
c, err := New(config.Config{
|
|
Root: t.TempDir(),
|
|
Upstream: "http://example.com/",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if got := c.Upstream(); got != "http://example.com" {
|
|
t.Errorf("Upstream() = %q, want trailing slash stripped", got)
|
|
}
|
|
}
|
|
|
|
func TestNew_BearerFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tokenPath := filepath.Join(dir, "token")
|
|
if err := os.WriteFile(tokenPath, []byte(" abc123\n"), 0o600); err != nil {
|
|
t.Fatalf("write token: %v", err)
|
|
}
|
|
c, err := New(config.Config{
|
|
Root: t.TempDir(),
|
|
Upstream: "http://example.com",
|
|
BearerFile: tokenPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if c.bearer != "abc123" {
|
|
t.Errorf("bearer = %q, want abc123 (whitespace trimmed)", c.bearer)
|
|
}
|
|
}
|
|
|
|
func TestNew_BearerFileEmptyRejected(t *testing.T) {
|
|
dir := t.TempDir()
|
|
empty := filepath.Join(dir, "empty")
|
|
_ = os.WriteFile(empty, []byte("\n\n"), 0o600)
|
|
if _, err := New(config.Config{
|
|
Root: t.TempDir(),
|
|
Upstream: "http://example.com",
|
|
BearerFile: empty,
|
|
}); err == nil {
|
|
t.Error("expected error for empty bearer file")
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_RejectsUnsupportedMethods(t *testing.T) {
|
|
// PUT/POST/DELETE are now supported (phase 4 outbox). Only
|
|
// methods we don't handle at all (PATCH, OPTIONS, etc.) get 405.
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("upstream should not be called for unsupported methods")
|
|
})
|
|
for _, method := range []string{http.MethodPatch, http.MethodOptions, "TRACE"} {
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(method, "/foo", nil)
|
|
c.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("%s = %d, want 405", method, rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Allow"); !strings.Contains(got, "GET") || !strings.Contains(got, "PUT") {
|
|
t.Errorf("%s Allow = %q", method, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_MissThenHit(t *testing.T) {
|
|
var hits int32
|
|
c, upstream := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&hits, 1)
|
|
if r.URL.Path != "/foo.txt" {
|
|
t.Errorf("upstream got %q, want /foo.txt", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Header().Set("Last-Modified", "Mon, 02 Jan 2006 15:04:05 GMT")
|
|
_, _ = w.Write([]byte("hello"))
|
|
})
|
|
_ = upstream
|
|
|
|
// First request: miss.
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/foo.txt", nil)
|
|
c.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("first GET = %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get(HeaderName); got != "miss" {
|
|
t.Errorf("first cache header = %q, want miss", got)
|
|
}
|
|
if got := rec.Body.String(); got != "hello" {
|
|
t.Errorf("body = %q", got)
|
|
}
|
|
|
|
// Cache file should exist.
|
|
cached := filepath.Join(c.root, "foo.txt")
|
|
if _, err := os.Stat(cached); err != nil {
|
|
t.Fatalf("expected cached file: %v", err)
|
|
}
|
|
|
|
// Second request: hit. Wait briefly to let the marker write race finish.
|
|
rec2 := httptest.NewRecorder()
|
|
r2 := httptest.NewRequest(http.MethodGet, "/foo.txt", nil)
|
|
c.ServeHTTP(rec2, r2)
|
|
if rec2.Code != http.StatusOK {
|
|
t.Fatalf("second GET = %d", rec2.Code)
|
|
}
|
|
if got := rec2.Header().Get(HeaderName); got != "hit" {
|
|
t.Errorf("second cache header = %q, want hit", got)
|
|
}
|
|
if got := rec2.Body.String(); got != "hello" {
|
|
t.Errorf("second body = %q", got)
|
|
}
|
|
|
|
// Marker file should be present.
|
|
marker := filepath.Join(c.root, MarkerFile)
|
|
mb, err := os.ReadFile(marker)
|
|
if err != nil {
|
|
t.Fatalf("marker missing: %v", err)
|
|
}
|
|
if !strings.Contains(string(mb), "upstream:") {
|
|
t.Errorf("marker contents unexpected: %s", string(mb))
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_ProxyModeDoesNotPersist(t *testing.T) {
|
|
c, _ := newTestCache(t, "proxy", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("payload"))
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/foo.txt", nil)
|
|
c.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get(HeaderName); got != "proxy" {
|
|
t.Errorf("cache header = %q, want proxy", got)
|
|
}
|
|
cached := filepath.Join(c.root, "foo.txt")
|
|
if _, err := os.Stat(cached); !os.IsNotExist(err) {
|
|
t.Errorf("proxy mode wrote to cache: %v", err)
|
|
}
|
|
// Marker also shouldn't exist (no caching happened).
|
|
if _, err := os.Stat(filepath.Join(c.root, MarkerFile)); !os.IsNotExist(err) {
|
|
t.Errorf("marker file written in proxy mode")
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_DirectoryListingsCachedAsSidecar(t *testing.T) {
|
|
var hits int32
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&hits, 1)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write([]byte("<html>listing</html>"))
|
|
})
|
|
// First request: miss, body served + sidecar written.
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/Project/", nil)
|
|
r.Header.Set("Accept", "text/html")
|
|
c.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get(HeaderName); got != "miss" {
|
|
t.Errorf("first cache header = %q, want miss", got)
|
|
}
|
|
sidecar := filepath.Join(c.root, "Project", listingCachePrefix+"html")
|
|
if _, err := os.Stat(sidecar); err != nil {
|
|
t.Fatalf("expected listing sidecar: %v", err)
|
|
}
|
|
// Second request: hit.
|
|
rec2 := httptest.NewRecorder()
|
|
r2 := httptest.NewRequest(http.MethodGet, "/Project/", nil)
|
|
r2.Header.Set("Accept", "text/html")
|
|
c.ServeHTTP(rec2, r2)
|
|
if got := rec2.Header().Get(HeaderName); got != "hit" {
|
|
t.Errorf("second cache header = %q, want hit", got)
|
|
}
|
|
if rec2.Body.String() != "<html>listing</html>" {
|
|
t.Errorf("body = %q", rec2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_ListingFormatVariesByAccept(t *testing.T) {
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
|
_, _ = w.Write([]byte(`[{"name":"foo"}]`))
|
|
} else {
|
|
_, _ = w.Write([]byte("<html>html</html>"))
|
|
}
|
|
})
|
|
// JSON request → JSON sidecar.
|
|
rec := httptest.NewRecorder()
|
|
rj := httptest.NewRequest(http.MethodGet, "/Project/", nil)
|
|
rj.Header.Set("Accept", "application/json")
|
|
c.ServeHTTP(rec, rj)
|
|
if !strings.Contains(rec.Body.String(), "foo") {
|
|
t.Errorf("json body = %q", rec.Body.String())
|
|
}
|
|
// HTML request → HTML sidecar (separately).
|
|
rec2 := httptest.NewRecorder()
|
|
rh := httptest.NewRequest(http.MethodGet, "/Project/", nil)
|
|
rh.Header.Set("Accept", "text/html")
|
|
c.ServeHTTP(rec2, rh)
|
|
if !strings.Contains(rec2.Body.String(), "html") {
|
|
t.Errorf("html body = %q", rec2.Body.String())
|
|
}
|
|
// Both sidecars exist.
|
|
if _, err := os.Stat(filepath.Join(c.root, "Project", listingCachePrefix+"json")); err != nil {
|
|
t.Errorf("json sidecar missing: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(c.root, "Project", listingCachePrefix+"html")); err != nil {
|
|
t.Errorf("html sidecar missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_ListingOfflineServesStale(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(root, "Project"), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Project", listingCachePrefix+"html"), []byte("<cached/>"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
c, err := New(config.Config{Root: root, Upstream: "http://127.0.0.1:1", Mode: "cache"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
c.client.Timeout = 200 * time.Millisecond
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/Project/", nil)
|
|
r.Header.Set("Accept", "text/html")
|
|
c.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("offline listing = %d, want 200", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "<cached/>") {
|
|
t.Errorf("body = %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_HEAD_HitDoesNotReturnBody(t *testing.T) {
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("hello"))
|
|
})
|
|
// Seed the cache via GET.
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/foo.txt", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("seed: %d", rec.Code)
|
|
}
|
|
|
|
// HEAD: should be a hit, no body.
|
|
rec2 := httptest.NewRecorder()
|
|
c.ServeHTTP(rec2, httptest.NewRequest(http.MethodHead, "/foo.txt", nil))
|
|
if rec2.Code != http.StatusOK {
|
|
t.Fatalf("HEAD: %d", rec2.Code)
|
|
}
|
|
if rec2.Body.Len() != 0 {
|
|
t.Errorf("HEAD body length = %d, want 0", rec2.Body.Len())
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_OfflineServesStale(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Pre-seed a cached file.
|
|
if err := os.WriteFile(filepath.Join(root, "stale.txt"), []byte("stale-content"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
c, err := New(config.Config{
|
|
Root: root,
|
|
Upstream: "http://127.0.0.1:1", // unreachable port
|
|
Mode: "cache",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
// Speed up the timeout so the test doesn't hang.
|
|
c.client.Timeout = 200 * time.Millisecond
|
|
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/stale.txt", nil)
|
|
c.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("offline-with-cache = %d, want 200", rec.Code)
|
|
}
|
|
if got := rec.Header().Get(HeaderName); got != "hit" {
|
|
// On hit we don't even hit the network. That's expected.
|
|
t.Logf("first attempt was %q (likely cache hit before any network)", got)
|
|
}
|
|
if got := rec.Body.String(); got != "stale-content" {
|
|
t.Errorf("body = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_OfflineMissReturns503(t *testing.T) {
|
|
root := t.TempDir()
|
|
c, err := New(config.Config{
|
|
Root: root,
|
|
Upstream: "http://127.0.0.1:1",
|
|
Mode: "cache",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
c.client.Timeout = 200 * time.Millisecond
|
|
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/never-cached.txt", nil)
|
|
c.ServeHTTP(rec, r)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("offline-no-cache = %d, want 503", rec.Code)
|
|
}
|
|
if got := rec.Header().Get(HeaderName); got != "offline" {
|
|
t.Errorf("cache header = %q, want offline", got)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_BearerForwarded(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tokenPath := filepath.Join(dir, "token")
|
|
_ = os.WriteFile(tokenPath, []byte("secrettoken"), 0o600)
|
|
var seenAuth string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seenAuth = r.Header.Get("Authorization")
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
c, err := New(config.Config{
|
|
Root: t.TempDir(),
|
|
Upstream: upstream.URL,
|
|
Mode: "cache",
|
|
BearerFile: tokenPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/foo.txt", nil))
|
|
if seenAuth != "Bearer secrettoken" {
|
|
t.Errorf("Authorization = %q, want Bearer secrettoken", seenAuth)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_PreservesQuery(t *testing.T) {
|
|
var seenURL string
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
seenURL = r.URL.RequestURI()
|
|
w.Header().Set("Cache-Control", "no-store") // no-cache the JSON response
|
|
_, _ = w.Write([]byte(`{}`))
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/foo.txt?q=bar", nil))
|
|
if seenURL != "/foo.txt?q=bar" {
|
|
t.Errorf("upstream saw %q, want /foo.txt?q=bar", seenURL)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_HonorsNoStore(t *testing.T) {
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write([]byte("ephemeral"))
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dynamic.json", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get(HeaderName); got != "proxy" {
|
|
t.Errorf("cache header = %q, want proxy (no-store should bypass cache)", got)
|
|
}
|
|
cached := filepath.Join(c.root, "dynamic.json")
|
|
if _, err := os.Stat(cached); !os.IsNotExist(err) {
|
|
t.Errorf("no-store response was cached")
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_PathTraversalRejected(t *testing.T) {
|
|
called := false
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
_, _ = w.Write([]byte("data"))
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/../etc/passwd", nil))
|
|
// The upstream may still be called (the proxy doesn't gatekeep), but
|
|
// we MUST NOT cache to a path that escapes the root.
|
|
_ = called
|
|
root := c.root
|
|
parent := filepath.Dir(root)
|
|
if _, err := os.Stat(filepath.Join(parent, "etc", "passwd")); !os.IsNotExist(err) {
|
|
t.Error("path traversal wrote outside cache root")
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_ForwardsErrorStatus(t *testing.T) {
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/secret.txt", nil))
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", rec.Code)
|
|
}
|
|
cached := filepath.Join(c.root, "secret.txt")
|
|
if _, err := os.Stat(cached); !os.IsNotExist(err) {
|
|
t.Error("403 response was cached")
|
|
}
|
|
}
|
|
|
|
func TestRevalidate_PurgesOn403(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "victim.txt"), []byte("cached"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
}))
|
|
defer upstream.Close()
|
|
c, err := New(config.Config{Root: root, Upstream: upstream.URL, Mode: "cache"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
c.revalidate("/victim.txt", time.Now())
|
|
if _, err := os.Stat(filepath.Join(root, "victim.txt")); !os.IsNotExist(err) {
|
|
t.Error("revalidate did not purge after 403")
|
|
}
|
|
}
|
|
|
|
func TestRevalidate_PurgesOn404(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "gone.txt"), []byte("cached"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer upstream.Close()
|
|
c, err := New(config.Config{Root: root, Upstream: upstream.URL, Mode: "cache"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
c.revalidate("/gone.txt", time.Now())
|
|
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
|
t.Error("revalidate did not purge after 404")
|
|
}
|
|
}
|
|
|
|
func TestRevalidate_NoPurgeOn200ButRefreshes(t *testing.T) {
|
|
root := t.TempDir()
|
|
old := []byte("old-content")
|
|
if err := os.WriteFile(filepath.Join(root, "fresh.txt"), old, 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
// Set the file's mtime to an hour ago.
|
|
hourAgo := time.Now().Add(-time.Hour)
|
|
_ = os.Chtimes(filepath.Join(root, "fresh.txt"), hourAgo, hourAgo)
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("new-content"))
|
|
}))
|
|
defer upstream.Close()
|
|
c, err := New(config.Config{Root: root, Upstream: upstream.URL, Mode: "cache"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
c.revalidate("/fresh.txt", hourAgo)
|
|
got, _ := os.ReadFile(filepath.Join(root, "fresh.txt"))
|
|
if string(got) != "new-content" {
|
|
t.Errorf("revalidate did not refresh: got %q", string(got))
|
|
}
|
|
}
|
|
|
|
func TestRevalidate_NoOpOn304(t *testing.T) {
|
|
root := t.TempDir()
|
|
original := []byte("original")
|
|
if err := os.WriteFile(filepath.Join(root, "still.txt"), original, 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Always return 304; assume client sent If-Modified-Since.
|
|
if r.Header.Get("If-Modified-Since") == "" {
|
|
t.Errorf("revalidate did not send If-Modified-Since")
|
|
}
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}))
|
|
defer upstream.Close()
|
|
c, err := New(config.Config{Root: root, Upstream: upstream.URL, Mode: "cache"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
c.revalidate("/still.txt", time.Now())
|
|
got, _ := os.ReadFile(filepath.Join(root, "still.txt"))
|
|
if string(got) != "original" {
|
|
t.Errorf("304 caused content change: got %q", string(got))
|
|
}
|
|
}
|
|
|
|
func TestRangeRequest_Hit(t *testing.T) {
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte("0123456789"))
|
|
})
|
|
// Seed cache.
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/data.txt", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("seed: %d", rec.Code)
|
|
}
|
|
|
|
// Range request.
|
|
rec2 := httptest.NewRecorder()
|
|
r2 := httptest.NewRequest(http.MethodGet, "/data.txt", nil)
|
|
r2.Header.Set("Range", "bytes=2-5")
|
|
c.ServeHTTP(rec2, r2)
|
|
if rec2.Code != http.StatusPartialContent {
|
|
t.Fatalf("range = %d, want 206", rec2.Code)
|
|
}
|
|
if rec2.Body.String() != "2345" {
|
|
t.Errorf("range body = %q", rec2.Body.String())
|
|
}
|
|
if got := rec2.Header().Get("Content-Range"); !strings.HasPrefix(got, "bytes 2-5/") {
|
|
t.Errorf("Content-Range = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_ConcurrentRequestsForSameURL(t *testing.T) {
|
|
// Stress the marker-once and tmpfile path with parallel misses.
|
|
var hits int32
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&hits, 1)
|
|
_, _ = io.WriteString(w, "concurrent")
|
|
})
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/c.txt", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d", rec.Code)
|
|
}
|
|
if rec.Body.String() != "concurrent" {
|
|
t.Errorf("body = %q", rec.Body.String())
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// File should exist with the right content.
|
|
got, err := os.ReadFile(filepath.Join(c.root, "c.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if string(got) != "concurrent" {
|
|
t.Errorf("cached body = %q", string(got))
|
|
}
|
|
}
|
|
|
|
func TestCachePathFor_Boundaries(t *testing.T) {
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {})
|
|
cases := []struct {
|
|
urlPath string
|
|
ok bool
|
|
}{
|
|
{"", false},
|
|
{"/", false},
|
|
{"/../etc/passwd", false},
|
|
{"/foo/../bar", false},
|
|
{"/foo/bar.txt", true},
|
|
{"/" + MarkerFile, false},
|
|
{"/Project/foo.txt", true},
|
|
}
|
|
for _, tc := range cases {
|
|
_, ok := c.cachePathFor(tc.urlPath)
|
|
if ok != tc.ok {
|
|
t.Errorf("cachePathFor(%q) ok=%v, want %v", tc.urlPath, ok, tc.ok)
|
|
}
|
|
}
|
|
}
|