The previous keyForURL stripped default ports (:443 for https, :80 for http) and omitted the scheme, so: http://example.com/x.html ──┐ https://example.com/x.html ──┴──→ same cache entry (collision) https://example.com/x.html ──┐ https://example.com:443/x.html ──┴──→ same cache entry This was a defensible HTTP convention but a real correctness issue on reverse-proxy stacks where http and https legitimately serve different bytes for the same path, or where two upstreams share a host but answer on different default ports. New layout: <scheme>/<host>[:<port>]/<path>. Full origin tuple in the key, no port stripping, scheme segregation. Examples: https/zddc.varasys.io/releases/archive_stable.html https/example.com:8443/x.html http/example.com/y.html (distinct from https/example.com/y.html) Operators retain the "ls _app/ to inspect what's cached" affordance they relied on; they just see one extra directory layer (scheme first, then host). Tests: * Updated TestKeyForURL to assert the new layout for every previously-covered case * New TestKeyForURL_NoCollisions explicitly asserts that the dimensions previously collapsed (default-port↔bare, http↔https, different non-default ports) now produce distinct keys Doc references to the cache layout under <ZDDC_ROOT>/_app/ updated in zddc/README.md (3 mentions). NOTE: existing _app/ caches under the old layout will be ignored on first request after upgrade — entries will be re-fetched and written to the new path. Operators can `rm -rf <ZDDC_ROOT>/_app` during the upgrade window if they prefer not to have orphans. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
4.8 KiB
Go
160 lines
4.8 KiB
Go
package apps
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestKeyForURL(t *testing.T) {
|
|
cases := []struct {
|
|
raw, want string
|
|
}{
|
|
// Default ports are PRESERVED — no port-stripping (the previous
|
|
// behavior conflated "operator wrote :443" with "operator wrote
|
|
// bare host"; with the full origin in the key, every URL maps
|
|
// to exactly one path).
|
|
{"https://zddc.varasys.io/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
|
|
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
|
|
{"http://example.com/foo.html", "http/example.com/foo.html"},
|
|
{"http://example.com:80/foo.html", "http/example.com:80/foo.html"},
|
|
{"https://example.com/foo.html", "https/example.com/foo.html"},
|
|
{"https://example.com:443/foo.html", "https/example.com:443/foo.html"},
|
|
{"https://example.com:8443/foo.html", "https/example.com:8443/foo.html"},
|
|
// Scheme segregation: same host+path under http and https map
|
|
// to different cache entries (defensive against reverse-proxy
|
|
// stacks that legitimately serve different bytes per scheme).
|
|
{"http://example.com/x.html", "http/example.com/x.html"},
|
|
{"https://example.com/x.html", "https/example.com/x.html"},
|
|
// Path normalization preserved.
|
|
{"https://example.com/", "https/example.com/index.html"},
|
|
{"https://example.com", "https/example.com/index.html"},
|
|
{"https://example.com//foo//bar.html", "https/example.com/foo/bar.html"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.raw, func(t *testing.T) {
|
|
got, err := keyForURL(tc.raw)
|
|
if err != nil {
|
|
t.Fatalf("keyForURL error: %v", err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("got %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestKeyForURL_NoCollisions: explicit assertion that the dimensions
|
|
// previously collapsed (default-port ↔ bare-host, http ↔ https) are
|
|
// now distinct. Any future change that re-introduces collapsing will
|
|
// fail this test.
|
|
func TestKeyForURL_NoCollisions(t *testing.T) {
|
|
pairs := [][2]string{
|
|
// Different scheme, same host+path
|
|
{"http://example.com/x.html", "https://example.com/x.html"},
|
|
// https default port preserved (not collapsed onto bare host)
|
|
{"https://example.com/x.html", "https://example.com:443/x.html"},
|
|
// http default port preserved
|
|
{"http://example.com/x.html", "http://example.com:80/x.html"},
|
|
// Different non-default ports
|
|
{"https://example.com:8443/x.html", "https://example.com:9443/x.html"},
|
|
}
|
|
for _, p := range pairs {
|
|
t.Run(p[0]+" vs "+p[1], func(t *testing.T) {
|
|
a, err := keyForURL(p[0])
|
|
if err != nil {
|
|
t.Fatalf("keyForURL(%q): %v", p[0], err)
|
|
}
|
|
b, err := keyForURL(p[1])
|
|
if err != nil {
|
|
t.Fatalf("keyForURL(%q): %v", p[1], err)
|
|
}
|
|
if a == b {
|
|
t.Errorf("collision: %q and %q both → %q", p[0], p[1], a)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKeyForURL_Errors(t *testing.T) {
|
|
cases := []string{
|
|
"",
|
|
"not-a-url",
|
|
"ftp://example.com/x.html",
|
|
"https:///x.html",
|
|
"https://example.com/x.html?v=1",
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc, func(t *testing.T) {
|
|
if _, err := keyForURL(tc); err == nil {
|
|
t.Errorf("keyForURL(%q) = nil, want error", tc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCacheRoundtrip(t *testing.T) {
|
|
c, err := NewCache(filepath.Join(t.TempDir(), "_app"))
|
|
if err != nil {
|
|
t.Fatalf("NewCache: %v", err)
|
|
}
|
|
urlStr := "https://zddc.varasys.io/releases/archive_stable.html"
|
|
body := []byte("<!DOCTYPE html>archive content")
|
|
|
|
if c.Has(urlStr) {
|
|
t.Fatalf("Has(empty cache) = true, want false")
|
|
}
|
|
if err := c.Write(urlStr, body); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
if !c.Has(urlStr) {
|
|
t.Fatalf("Has(after write) = false, want true")
|
|
}
|
|
got, err := c.Read(urlStr)
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(got) != string(body) {
|
|
t.Errorf("body mismatch")
|
|
}
|
|
}
|
|
|
|
func TestCacheAtomicWrite_LeavesNoTempOnSuccess(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "_app")
|
|
c, _ := NewCache(root)
|
|
urlStr := "https://zddc.varasys.io/releases/archive_stable.html"
|
|
if err := c.Write(urlStr, []byte("hello")); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
count := 0
|
|
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() && strings.Contains(info.Name(), ".tmp.") {
|
|
count++
|
|
}
|
|
return nil
|
|
})
|
|
if count != 0 {
|
|
t.Errorf("found %d .tmp.* leftovers, want 0", count)
|
|
}
|
|
}
|
|
|
|
func TestCacheSweepsTempsOnNew(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "_app")
|
|
stale := filepath.Join(root, "example.com", "releases", "archive_stable.html.tmp.123")
|
|
if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(stale, []byte("partial"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := NewCache(root); err != nil {
|
|
t.Fatalf("NewCache: %v", err)
|
|
}
|
|
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
|
t.Errorf("stale tmp file not swept: %v", err)
|
|
}
|
|
}
|