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>
292 lines
10 KiB
Go
292 lines
10 KiB
Go
package apps
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
func TestMatchAppHTML(t *testing.T) {
|
|
cases := []struct {
|
|
path, wantApp, wantDir string
|
|
}{
|
|
{"/", "landing", ""},
|
|
{"/index.html", "landing", ""},
|
|
{"/archive.html", "archive", ""},
|
|
{"/Project-X/archive.html", "archive", "Project-X"},
|
|
{"/Project-X/Working/mdedit.html", "mdedit", "Project-X/Working"},
|
|
{"/foo.html", "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
gotApp, gotDir := MatchAppHTML(tc.path)
|
|
if gotApp != tc.wantApp || gotDir != tc.wantDir {
|
|
t.Errorf("got (%q,%q), want (%q,%q)", gotApp, gotDir, tc.wantApp, tc.wantDir)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Build a Server with a fake upstream serving body.
|
|
func newTestServer(t *testing.T, body []byte) (*Server, *httptest.Server, string) {
|
|
t.Helper()
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write(body)
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
root := t.TempDir()
|
|
cache, err := NewCache(filepath.Join(root, CacheDirName))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := NewFetcher(cache, nil)
|
|
return NewServer(root, cache, f, "test"), upstream, root
|
|
}
|
|
|
|
func TestServer_NoOverride_ServesEmbedded(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("upstream body"))
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED archive")
|
|
defer func() { embeddedArchive = saved }()
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
|
|
t.Errorf("expected embedded body, got %q", rec.Body.String())
|
|
}
|
|
if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "embedded:archive@") {
|
|
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
|
|
}
|
|
}
|
|
|
|
func TestServer_OverrideURL_FetchesAndCaches(t *testing.T) {
|
|
body := []byte("from upstream")
|
|
srv, up, root := newTestServer(t, body)
|
|
chain := zddc.PolicyChain{
|
|
Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"archive": up.URL + "/archive_stable.html"},
|
|
}},
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if rec.Body.String() != string(body) {
|
|
t.Errorf("body mismatch")
|
|
}
|
|
// Cache should be populated.
|
|
if !srv.Cache.Has(up.URL + "/archive_stable.html") {
|
|
t.Errorf("cache miss after fetch")
|
|
}
|
|
}
|
|
|
|
func TestServer_OverrideURL_CacheHitOnSecondCall(t *testing.T) {
|
|
var hits atomic.Int64
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
_, _ = w.Write([]byte("body"))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
root := t.TempDir()
|
|
cache, _ := NewCache(filepath.Join(root, CacheDirName))
|
|
f := NewFetcher(cache, nil)
|
|
srv := NewServer(root, cache, f, "test")
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"archive": upstream.URL + "/archive_stable.html"},
|
|
}}}
|
|
for i := 0; i < 3; i++ {
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("call %d status=%d", i, rec.Code)
|
|
}
|
|
}
|
|
if hits.Load() != 1 {
|
|
t.Errorf("upstream fetched %d times, want exactly 1 (cache forever)", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestServer_PathOverride_ServedDirectly(t *testing.T) {
|
|
root := t.TempDir()
|
|
pathFile := filepath.Join(root, "local.html")
|
|
body := []byte("local archive bytes")
|
|
if err := os.WriteFile(pathFile, body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache, _ := NewCache(filepath.Join(root, CacheDirName))
|
|
f := NewFetcher(cache, nil)
|
|
srv := NewServer(root, cache, f, "test")
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
{Apps: map[string]string{"archive": "./local.html"}},
|
|
}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if rec.Body.String() != string(body) {
|
|
t.Errorf("body mismatch")
|
|
}
|
|
if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "path:") {
|
|
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
|
|
}
|
|
}
|
|
|
|
func TestServer_FetchFailFallsBackToEmbedded(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ok"))
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED")
|
|
defer func() { embeddedArchive = saved }()
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"archive": "https://no-such.example/archive.html"},
|
|
}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d (want 200 from embedded)", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
|
|
t.Errorf("body did not come from embedded fallback: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// ── ?v= per-request override ─────────────────────────────────────────────
|
|
|
|
func TestServer_VParam_CacheHitServesFromCache(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
// Pre-populate the cache with a known URL.
|
|
cachedURL := "https://zddc.varasys.io/releases/archive_beta.html"
|
|
cachedBody := []byte("CACHED beta archive")
|
|
if err := srv.Cache.Write(cachedURL, cachedBody); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != string(cachedBody) {
|
|
t.Errorf("body=%q, want CACHED bytes", rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
|
|
t.Errorf("X-ZDDC-Source=%q", got)
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_CacheMissReturns404(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=beta", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status=%d (want 404)", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "not in the local cache") {
|
|
t.Errorf("body should explain cache miss, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_RejectsPathSource(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=./local.html", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status=%d (want 400 for path source via ?v=)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_BadSpecReturns400(t *testing.T) {
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=not%20a%20spec", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status=%d (want 400)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
|
|
// Cascade has a default URL prefix; ?v=:beta should resolve against it.
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
cachedURL := "https://my-mirror.example/releases/archive_beta.html"
|
|
if err := srv.Cache.Write(cachedURL, []byte("MIRROR beta")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"},
|
|
}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:beta", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "MIRROR beta" {
|
|
t.Errorf("body=%q", rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
|
|
t.Errorf("X-ZDDC-Source=%q (expected mirror URL)", got)
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) {
|
|
// Operator's cascade specifies a path source. User passes ?v=stable.
|
|
// ?v= overrides → resolves to canonical/archive_stable.html, then cache check.
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
cachedURL := "https://zddc.varasys.io/releases/archive_stable.html"
|
|
if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pathFile := filepath.Join(root, "operator-version.html")
|
|
if err := os.WriteFile(pathFile, []byte("OPERATOR PATH"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
|
Apps: map[string]string{"archive": "./operator-version.html"},
|
|
}}}
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=stable", nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "CACHED stable" {
|
|
t.Errorf("body=%q (expected ?v= override to win)", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServer_VParam_FullURLForm(t *testing.T) {
|
|
// `?v=https://my-fork/archive.html` — terminal full URL, must be cached.
|
|
srv, _, root := newTestServer(t, []byte("ignored"))
|
|
cachedURL := "https://my-fork.example/custom.html"
|
|
if err := srv.Cache.Write(cachedURL, []byte("FORK custom")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
target := "/archive.html?v=" + url.QueryEscape(cachedURL)
|
|
rec := httptest.NewRecorder()
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, target, nil), "archive", chain, root)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "FORK custom" {
|
|
t.Errorf("body=%q", rec.Body.String())
|
|
}
|
|
}
|