zddc-server can now run as a downstream client of another zddc-server. Set --upstream <url> and the master-side machinery (archive index, apps server, watcher, OPA decider, ACL middleware, token store) is bypassed entirely; cmd/zddc-server/main.go short-circuits to runClient(cfg) which uses zddc/internal/cache/Cache as the entire request handler. Three modes via --mode <proxy|cache|mirror>: - proxy: forward upstream live, no disk persistence - cache (default): persist responses on access; subsequent hits serve from disk + background If-Modified-Since revalidate - mirror: accepted but currently behaves like cache; the access- triggered walker lands in phase 3 Cache directory layout is intentionally a normal ZDDC root: a file fetched from <master>/foo/bar.txt is stored at <root>/foo/bar.txt with no sidecar metadata. The local file's mtime is set to the upstream's Last-Modified header so revalidation reflects the master's notion of file age, not local fetch time. Running zddc-server --root <cache-dir> without --upstream serves the cached files as a plain master — useful for portable offline snapshots. A small .zddc-upstream marker is written once on first persist for provenance. Pipeline (GET/HEAD only — writes deferred): - Hit → http.ServeContent serves directly (range-aware, 304-aware) + background revalidate (304 no-op, 200 overwrite, 403/404 purge) - Miss → forward to upstream with the configured bearer; tee response body to client + tmp-file atomically renamed into the cache - Network error + cached → serve stale + X-ZDDC-Cache: offline - Network error + no cache → 503 + X-ZDDC-Cache: offline - Directories always proxy live (no listing cache yet — phase 3) - Cache-Control: no-store / private and non-200 responses bypass cache Range requests work end-to-end (Range/If-Range headers forwarded on miss; http.ServeContent handles them natively on hit). Hop-by-hop headers per RFC 7230 §6.1 are dropped from forwarded responses. New flags (also as ZDDC_* env vars), all ignored when --upstream is empty (so master deployments are untouched): - --upstream <url> - --mode proxy|cache|mirror (default cache) - --bearer-file <path> (0600 file with the master-issued token) - --skip-tls-verify (separate from --no-auth; for self-signed dev) Validation: --upstream must be http(s)://...; trailing / is trimmed. Mode validated to one of the three known values. The startup no-root-.zddc check is skipped in client mode (the cache directory starts empty by design). The plain-HTTP-on-non-loopback check is also skipped (the local instance never reads the email header to decide anything; auth is forwarded to upstream as a Bearer). Tests: zddc/internal/cache/cache_test.go runs httptest.NewServer as the upstream and covers miss-then-hit, proxy-mode-no-persist, directory-never-cached, HEAD-no-body, offline-with-cache, offline-no-cache → 503, bearer forwarding, query-string preservation, no-store bypass, path-traversal rejection, error-status forwarding, revalidate-on-403/404/200/304, range-on-hit, concurrent-same-URL, cache-path boundary cases. 23 new tests, full suite + go vet clean. Live two-instance smoke verified: master at 127.0.0.1:18443, client at :18444 with --mode cache, miss→hit→hit transitions work, file materialises under cache root with parent dirs created, marker file written once, range-on-hit returns 206, master sees background 304s on every hit, killing master leaves cached files serving from disk and never-cached files returning 503 + offline header. Doc updates: zddc/README.md gains a "Client mode" section with the modes table, flag reference, pipeline summary, two-instance recipe, and explicit list of phase-2 limitations; AGENTS.md adds the four new env vars to the reference table and a "Client mode" subsection with smoke-test recipe and a pointer to the cache package; ARCHITECTURE.md adds "Master + proxy/cache/mirror" before "Bearer token issuance," covering the topology, the persist/warm switches, the cache-IS-a-ZDDC-root invariant, the request pipeline, and the v1-out-of-scope multi-tenancy note; CLAUDE.md's zddc/ entry expanded to mention both deployment shapes so future agents pick it up by default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
546 lines
16 KiB
Go
546 lines
16 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)
|
|
}
|
|
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_RejectsWriteMethods(t *testing.T) {
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("upstream should not be called for write methods")
|
|
})
|
|
for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
|
|
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"); got != "GET, HEAD" {
|
|
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_DirectoriesAreNeverCached(t *testing.T) {
|
|
c, _ := newTestCache(t, "cache", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write([]byte("<html>listing</html>"))
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/Project/", 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 (directories don't cache)", got)
|
|
}
|
|
// No file or directory should have been created at the URL location.
|
|
if entries, _ := os.ReadDir(c.root); len(entries) > 0 {
|
|
t.Errorf("directory request created cache entries: %v", entries)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|