PUT / POST / DELETE in client mode now work end-to-end. Online: the
cache layer forwards to upstream and (on success) drops any cached
entry for the path so the next read fetches fresh. PUT/DELETE include
If-Unmodified-Since derived from the cached file's mtime so the master
can reject conflicting writes with 412 Precondition Failed.
When upstream is unreachable, the request is captured in the outbox
at <root>/.zddc-outbox/<id>/ — directory per queued write, mode 0700,
containing meta.json (method, RawURI, Content-Type, base mtime,
queued-at) and body.bin (request body, capped at 256 MiB). The client
gets 202 Accepted + X-ZDDC-Cache: queued and a JSON envelope.
A background replay loop started by runClient processes the queue:
- 2xx → delete entry; drop cached path so next read fetches fresh
- 412 → rename to <id>.conflict-<RFC3339>/ for manual reconciliation
(body + meta intact for inspection or re-submit)
- 4xx other → drop (retry won't help; logged at WARN)
- 5xx / transport error → leave for next pass
Replay schedule: eager at startup, then 30s while pending falling
back to 5min while idle. Loop honors graceful-shutdown context.
Disabled in --mode=proxy (proxy persists nothing by design — offline
writes return 503 instead of queueing).
Outbox IDs are <unix-nano-base16>-<hex-random> so lex-sort = queue
order; concurrent enqueues never collide. Conflict-rename appends a
4-char random suffix on the unlikely same-second collision.
The local cache is intentionally not updated for offline writes:
until upstream confirms the user reads still see the upstream-cached
version (or 503 if uncached). Trade-off: no "did my queued write
actually win?" ambiguity, at the cost of not seeing one's own
offline edits immediately. Phase 5 will surface .conflict-<ts>/
directories in browse views.
Tests (20 new in outbox_test.go, 5 new in cache_test.go covering
the write path): NewOutbox creates 0700 dir, Enqueue persists meta
+ body, Pending returns lex-sorted entries excluding conflicts,
Replay deletes on 2xx / renames on 412 / leaves on transport error
/ leaves on 5xx / drops on 4xx-other, IUS sent only for PUT/DELETE
with base mtime, query string preserved, ServeHTTP online write
forwards + evicts cache, ServeHTTP offline write queues with 202,
ServeHTTP offline + no outbox returns 503, ServeHTTP PUT sends IUS
from cached mtime, oversize body rejected, IDs lex-sortable,
RunReplayLoop stops on context cancel, concurrent Enqueue 30×
no collisions. Full suite + go vet clean.
Doc updates: zddc/README.md gains a "Writes (online + offline
outbox)" subsection covering both paths and replay outcomes;
"What client mode is NOT, yet" now lists only conflict UI and
multi-tenancy. AGENTS.md client-mode pipeline gains writes +
mirror-mode bullets. ARCHITECTURE.md adds a "Writes: outbox +
offline replay" subsection with the trade-off rationale and the
phase-5-deferred conflict UI hand-off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
621 lines
19 KiB
Go
621 lines
19 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_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)
|
|
}
|
|
}
|
|
}
|