ZDDC/zddc/internal/apps/cache_test.go
ZDDC 8b6a2dc3e3 feat(zddc-server): apps fetch+cache subsystem with cascade overrides
Adds internal/apps/ package serving the five tool HTMLs at virtual paths
based on the surrounding folder name convention:

  archive      every directory (multi-project, project, archive, vendor)
  classifier   any Incoming/Working/Staging directory and subtree
  mdedit       any Working directory and subtree
  transmittal  any Staging directory and subtree
  landing      only at deployment root

The current-stable build of every tool is //go:embed'd into the binary
at compile time — that's the default with zero config. Operators
override per-directory via .zddc apps: entries; closer-to-leaf wins.

Spec syntax (in any apps: value):

  stable / beta / alpha / :stable          channel
  v0.0.4 / v0.0 / v0 / :v0.0.4              version
  https://my-mirror/releases                URL prefix only
  https://my-mirror/releases:beta           URL prefix + channel
  https://my-fork/archive.html              terminal full URL
  ./local.html / /abs/path.html             terminal local path

The special apps.default key provides a baseline URL prefix and channel
inherited by any app not overridden per-name. Per-axis cascade: a deeper
.zddc can override the URL, the channel, or both.

Cascade walks root→leaf; default applies first at each level, then the
per-app entry. Terminal sources (paths and full .html URLs) short-circuit
composition; deeper non-terminal entries override parent terminals.

URL sources fetch once on first request and cache forever in
<ZDDC_ROOT>/_app/<host>/<path> — different upstreams with the same
filename stay distinct. No background refresh, no SHA-256 verification:
operators delete the cache file to force a refetch. Concurrent misses
for the same source dedupe via a 30-line hand-rolled singleflight.

Per-request override: any user can append ?v=<spec> to a tool URL
(e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha, ?v=https://mirror/releases:beta)
to ask for a different build for one request. Security: ?v= serves
ONLY versions already in the cache (cache miss returns 404; path
sources are rejected outright with 400). Users cannot trigger
arbitrary upstream fetches via crafted URLs.

Failed URL fetches (network down, 5xx) fall back to embedded with a
one-time WARN log. The X-ZDDC-Source response header reports what
served: fetch:URL / cache:URL / path:/abs / embedded:<app>@<build>.

Wire-in (cmd/zddc-server/main.go): dispatch routes <dir>/<app>.html
through apps.MatchAppHTML + AppAvailableAt + apps.Server.Serve when
no real file exists. Direct URL access to /_app/... is blocked at
the dispatch layer — cached files must go through the apps resolver
so they get correct Content-Type and ACL gating.

Schema (internal/zddc/file.go): ZddcFile gains Apps map[string]string
for cascade overrides. Validator (internal/zddc/validate.go) accepts
the special "default" key alongside the five canonical app names and
all spec forms.

Removes ZDDC_APPS_* env vars (no admin UI, no refresh interval, no
upstream allow-list — the simpler model has fewer knobs).

40+ unit tests across the new package: parser shapes, cascade
resolution with default+per-app interactions, terminal short-circuit
semantics, ?v= cache-only enforcement, embedded fallback, atomic
cache writes, singleflight dedup. Plus end-to-end dispatch tests in
cmd/zddc-server/main_test.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:25:25 -05:00

116 lines
3 KiB
Go

package apps
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestKeyForURL(t *testing.T) {
cases := []struct {
raw, want string
}{
{"https://zddc.varasys.io/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "zddc.varasys.io/releases/archive_stable.html"},
{"http://example.com:80/foo.html", "example.com/foo.html"},
{"https://example.com:443/foo.html", "example.com/foo.html"},
{"https://example.com:8443/foo.html", "example.com:8443/foo.html"},
{"https://example.com/", "example.com/index.html"},
{"https://example.com", "example.com/index.html"},
{"https://example.com//foo//bar.html", "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)
}
})
}
}
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)
}
}