ZDDC/zddc/cmd/zddc-server/main_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

213 lines
7.6 KiB
Go

package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
// rejects requests whose URL contains a dot-prefixed segment (other than
// the recognized virtual prefixes .archive and /.profile handled separately).
//
// The guard exists so the in-image dev-shell can keep persistent state
// (settings, source clones, Go module cache) under /srv/.devshell on the
// same Azure Files PVC as served data without ever exposing those files
// via direct HTTP fetch.
func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
root := t.TempDir()
// Realistic shape: a project dir, a hidden top-level dir, and a hidden
// sibling of a normal file inside the project.
mustMkdir(t, filepath.Join(root, "Project-A"))
mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok")
mustMkdir(t, filepath.Join(root, ".devshell"))
mustMkdir(t, filepath.Join(root, ".devshell", "coder"))
mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "secret")
mustMkdir(t, filepath.Join(root, "Project-A", ".internal"))
mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
path string
wantStatus int
}{
// Hidden top-level dir — every shape blocked.
{"hidden top dir", "/.devshell/", http.StatusNotFound},
{"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound},
// Hidden segment under a real project dir — also blocked.
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
// Sanity: recognized virtual prefixes are NOT blocked. .archive falls
// through to its own handler (which 404s on missing tracking number);
// .profile is handled by ServeProfile and the page itself is public.
// /.admin no longer exists — it is hard-cut and falls through to the
// dot-prefix guard, which 404s.
{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler
{".profile not blocked by guard", "/.profile/", http.StatusOK}, // public page renders for anonymous
{".admin hard-cut → dot-prefix guard", "/.admin/whoami", http.StatusNotFound},
// Normal files unaffected.
{"plain file", "/Project-A/doc.txt", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("path=%q status=%d want=%d body=%q",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
// TestDispatchAppsResolution drives the full apps fetch+cache flow through
// dispatch() with a fake upstream. Confirms that:
// - GET / serves the landing app from the apps subsystem
// - GET /archive.html serves the archive app via fetch+cache
// - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
// - direct URL access to /_zddc/... is rejected
func TestDispatchAppsResolution(t *testing.T) {
root := t.TempDir()
body := []byte("<!doctype html>archive content")
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"v1"`)
_, _ = w.Write(body)
}))
defer upstream.Close()
upstreamURL, _ := url.Parse(upstream.URL)
upstreamHost := upstreamURL.Host
if i := strings.Index(upstreamHost, ":"); i >= 0 {
upstreamHost = upstreamHost[:i]
}
_ = upstreamHost // referenced below
// Seed root .zddc with subdir-cascade Apps entries pointing at the
// fake upstream. Allow all email patterns (anonymous) so the test
// doesn't have to set up email headers.
zf := zddc.ZddcFile{
ACL: zddc.ACLRules{Allow: []string{"*"}},
Apps: map[string]string{
"archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html",
"classifier": upstream.URL + "/classifier_stable.html",
"mdedit": upstream.URL + "/mdedit_stable.html",
"landing": upstream.URL + "/landing_stable.html",
},
}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Create folder convention dirs so classifier/mdedit/transmittal
// availability rules pass for the test paths used below.
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{
Root: root,
IndexPath: ".archive",
EmailHeader: "X-Auth-Request-Email",
BuildVersion: "test-build",
}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// GET /archive.html → fetched from upstream (archive is available everywhere)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
dispatch(cfg, idx, ring, appsSrv, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(body) {
t.Errorf("first /archive.html: body mismatch")
}
// GET /archive.html again → cache hit (no new upstream fetch)
rec2 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
if rec2.Code != http.StatusOK {
t.Errorf("second /archive.html: status=%d", rec2.Code)
}
// GET / → landing
rec3 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
if rec3.Code != http.StatusOK {
t.Errorf("GET /: status=%d", rec3.Code)
}
// Direct URL access to /_app/ → 404
rec4 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
if rec4.Code != http.StatusNotFound {
t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code)
}
// Folder availability rules: classifier should NOT be served at root
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
// /Project-A/Working/.
rec5 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
if rec5.Code != http.StatusNotFound {
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
}
rec6 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
if rec6.Code != http.StatusOK {
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
}
}
// silence "imported and not used" if apps not referenced elsewhere — keep
// import even when we trim test cases later.
var _ = apps.DefaultUpstream
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
}
func mustWrite(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}