Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.
Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
/ a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
server-side via internal/zipfs (local file, no fetch, no signature;
re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.
Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
walker merges, cascade-summary adds, validate.go apps validation
(ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
apps:/apps_pubkey: in an existing .zddc is now silently ignored
(back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
drops the apps/apps_pubkey keys + appsmap case.
Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
server reads members from the filesystem internally.
Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
6.1 KiB
Go
191 lines
6.1 KiB
Go
package apps
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"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/browse.html", "browse", "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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// serve runs srv.Serve for app and returns the recorder.
|
|
func serve(srv *Server, app string) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/"+app+".html", nil), app, chain, srv.Root)
|
|
return rec
|
|
}
|
|
|
|
func TestServer_NoBundle_ServesEmbedded(t *testing.T) {
|
|
srv := NewServer(t.TempDir(), "test")
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED archive")
|
|
defer func() { embeddedArchive = saved }()
|
|
|
|
rec := serve(srv, "archive")
|
|
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_BundleMemberOverridesEmbedded(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestBundle(t, root, map[string]string{"archive.html": "BUNDLE archive override"})
|
|
srv := NewServer(root, "test")
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED archive")
|
|
defer func() { embeddedArchive = saved }()
|
|
|
|
rec := serve(srv, "archive")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "BUNDLE archive override") {
|
|
t.Errorf("expected bundle body, got %q", rec.Body.String())
|
|
}
|
|
if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" {
|
|
t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source"))
|
|
}
|
|
}
|
|
|
|
func TestServer_BundlePresent_MemberAbsent_ServesEmbedded(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse"})
|
|
srv := NewServer(root, "test")
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED archive")
|
|
defer func() { embeddedArchive = saved }()
|
|
|
|
rec := serve(srv, "archive") // bundle has browse, not archive
|
|
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
|
|
t.Errorf("expected embedded fallback, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServer_UnknownTool_503WithoutBundle(t *testing.T) {
|
|
srv := NewServer(t.TempDir(), "test")
|
|
rec := serve(srv, "nope") // not embedded, no bundle
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status=%d, want 503", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServer_Embedded_ConditionalGET(t *testing.T) {
|
|
srv := NewServer(t.TempDir(), "test")
|
|
|
|
saved := embeddedArchive
|
|
embeddedArchive = []byte("EMBEDDED archive bytes for ETag test")
|
|
defer func() {
|
|
embeddedArchive = saved
|
|
etagCacheByApp.Delete("archive")
|
|
}()
|
|
etagCacheByApp.Delete("archive")
|
|
|
|
rec1 := serve(srv, "archive")
|
|
if rec1.Code != http.StatusOK {
|
|
t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String())
|
|
}
|
|
etag := rec1.Header().Get("ETag")
|
|
if etag == "" {
|
|
t.Fatalf("first GET: missing ETag header")
|
|
}
|
|
if cc := rec1.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=0") || !strings.Contains(cc, "must-revalidate") {
|
|
t.Errorf("first GET: Cache-Control=%q", cc)
|
|
}
|
|
|
|
// Matching If-None-Match → 304, empty body.
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
|
req2.Header.Set("If-None-Match", etag)
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
srv.Serve(rec2, req2, "archive", chain, srv.Root)
|
|
if rec2.Code != http.StatusNotModified {
|
|
t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code)
|
|
}
|
|
if rec2.Body.Len() != 0 {
|
|
t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len())
|
|
}
|
|
|
|
// Stale If-None-Match → 200, full body.
|
|
rec3 := httptest.NewRecorder()
|
|
req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
|
req3.Header.Set("If-None-Match", `"deadbeef"`)
|
|
srv.Serve(rec3, req3, "archive", chain, srv.Root)
|
|
if rec3.Code != http.StatusOK || rec3.Body.Len() == 0 {
|
|
t.Errorf("stale If-None-Match: status=%d bodyLen=%d (want 200, non-empty)", rec3.Code, rec3.Body.Len())
|
|
}
|
|
}
|
|
|
|
// Bundle responses get a body-hash ETag and also short-circuit to 304.
|
|
func TestServer_Bundle_ConditionalGET(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse body"})
|
|
srv := NewServer(root, "test")
|
|
|
|
rec1 := serve(srv, "browse")
|
|
etag := rec1.Header().Get("ETag")
|
|
if rec1.Code != http.StatusOK || etag == "" {
|
|
t.Fatalf("first GET: status=%d etag=%q", rec1.Code, etag)
|
|
}
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/browse.html", nil)
|
|
req2.Header.Set("If-None-Match", etag)
|
|
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
|
srv.Serve(rec2, req2, "browse", chain, srv.Root)
|
|
if rec2.Code != http.StatusNotModified {
|
|
t.Errorf("bundle If-None-Match: status=%d (want 304)", rec2.Code)
|
|
}
|
|
}
|
|
|
|
// TestEmbeddedETag_Stable asserts EmbeddedETag is deterministic and
|
|
// content-addressed: same bytes → same ETag, different bytes → different.
|
|
func TestEmbeddedETag_Stable(t *testing.T) {
|
|
saved := embeddedArchive
|
|
defer func() {
|
|
embeddedArchive = saved
|
|
etagCacheByApp.Delete("archive")
|
|
}()
|
|
|
|
embeddedArchive = []byte("alpha")
|
|
etagCacheByApp.Delete("archive")
|
|
a1 := EmbeddedETag("archive")
|
|
a2 := EmbeddedETag("archive")
|
|
if a1 == "" || a1 != a2 {
|
|
t.Errorf("EmbeddedETag should be stable for same bytes; got %q vs %q", a1, a2)
|
|
}
|
|
|
|
embeddedArchive = []byte("beta")
|
|
etagCacheByApp.Delete("archive")
|
|
b := EmbeddedETag("archive")
|
|
if b == a1 {
|
|
t.Errorf("EmbeddedETag should change with bytes; both %q", b)
|
|
}
|
|
}
|