ZDDC/zddc/cmd/zddc-server/main_test.go
ZDDC 4eeb25c0ef feat(server): local-only tool-HTML override; remove apps URL/version fetching
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>
2026-06-04 08:59:28 -05:00

1059 lines
40 KiB
Go

package main
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"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"
)
// TestDispatchReservesZddcD asserts the dispatch() gate that reserves the one
// bookkeeping namespace, .zddc.d/. Non-admin requests to .zddc.d/ are 404'd at
// every depth (existence-hidden token store), while every OTHER dot-/underscore-
// prefixed path is ordinary ACL-governed content and is served like any normal
// file (a leading dot only hides it from listings). The recognized virtual
// prefixes (.archive, /.profile) are routed before the gate.
func TestDispatchReservesZddcD(t *testing.T) {
root := t.TempDir()
// Realistic shape: a project dir, a reserved .zddc.d/ token store, 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, ".zddc.d", "tokens"))
mustWrite(t, filepath.Join(root, ".zddc.d", "tokens", "abc123"), "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
}{
// Reserved .zddc.d/ bookkeeping — every shape 404'd for a non-admin
// (no ACLMiddleware here ⇒ unelevated principal).
{"reserved .zddc.d dir", "/.zddc.d/", http.StatusNotFound},
{"reserved .zddc.d token", "/.zddc.d/tokens/abc123", http.StatusNotFound},
// Case variants must ALSO be reserved: on a case-insensitive root
// (SMB/CIFS/Azure Files) `.ZDDC.D` resolves to the same dir, so the
// gate folds case (HasReservedSidecar uses EqualFold).
{"reserved .ZDDC.D upper", "/.ZDDC.D/tokens/abc123", http.StatusNotFound},
{"reserved .Zddc.D mixed", "/Project-A/.Zddc.D/history/x", http.StatusNotFound},
// The reserve's own .zddc cascade is hidden too: the sidecar gate runs
// before the raw .zddc view, so this never reaches ServeZddcFile.
{"reserved .zddc.d/.zddc cascade", "/Project-A/.zddc.d/.zddc", http.StatusNotFound},
// Other dot-prefixed content is no longer blocked by dispatch — it's
// ACL-governed like any file. This harness passes a nil decider, so the
// hidden file serves (the cascade is what gates it in production).
{"hidden dot content served", "/Project-A/.internal/notes.md", http.StatusOK},
// Sanity: recognized virtual prefixes are routed before the gate.
// .archive falls through to its own handler (404 on missing tracking);
// .profile is public. /.admin was hard-cut and now simply 404s as a
// not-found file (no dot-prefix guard left to reject it).
{".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 → not found", "/.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, 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 local tool-HTML resolution through
// dispatch(): the site .zddc.zip member overrides the embedded default, the
// embedded default is served when no bundle member exists, GET / serves
// landing, the bundle itself is 404 over HTTP, and folder-availability rules
// still gate which tools are served where.
func TestDispatchAppsResolution(t *testing.T) {
root := t.TempDir()
// Allow-all ACL so the test doesn't need email headers.
zf := zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}}}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Site config bundle overriding archive.html.
writeRootBundle(t, root, map[string]string{"archive.html": "<!doctype html>BUNDLE archive"})
// Folder-convention dir so classifier availability passes below.
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
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)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// GET /archive.html → served from the bundle member (overrides embedded).
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "BUNDLE archive") {
t.Fatalf("/archive.html: status=%d body=%s (want bundle override)", rec.Code, 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"))
}
// GET / → landing (no bundle member → embedded).
rec3 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
if rec3.Code != http.StatusOK {
t.Errorf("GET /: status=%d", rec3.Code)
}
// The site bundle is config, not content: a direct GET is 404 for everyone.
rec4 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.zip", nil))
if rec4.Code != http.StatusNotFound {
t.Errorf("GET /.zddc.zip: status=%d, want 404", rec4.Code)
}
// Folder availability rules: classifier should NOT be served at root
// (root has no per-party working/staging/incoming ancestor), but
// SHOULD work at /Project-A/archive/<party>/working/ where the per-
// party cascade declares classifier available.
rec5 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, 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 per-party working/staging/incoming)", rec5.Code)
}
rec6 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/classifier.html", nil))
if rec6.Code != http.StatusOK {
t.Errorf("/Project-A/working/Acme/classifier.html: status=%d, want 200", rec6.Code)
}
}
// TestDispatchRootAppShellPublicButDataGated locks in the root-path tool-shell
// bypass: a non-root, un-elevated user (no read grant anywhere at/under root)
// can GET /archive.html — the shell is a static app, served like the landing
// page — but the underlying data stays ACL-gated, so the same user is still
// Forbidden from reading a project directory they have no grant on. This is
// what makes the root-level multi-project archive (/archive.html?projects=A,B)
// usable by per-project-scoped users without admin elevation.
func TestDispatchRootAppShellPublicButDataGated(t *testing.T) {
root := t.TempDir()
// Root grants only alice; eve has no read grant at root or anywhere under it.
zf := zddc.ZddcFile{
ACL: zddc.ACLRules{Permissions: map[string]string{"alice@example.com": "rwcd"}},
}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
mustMkdir(t, filepath.Join(root, "Project-A"))
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)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// eve: no grant anywhere, NOT elevated (ElevatedKey unset → false).
eveReq := func(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
return req.WithContext(context.WithValue(req.Context(), handler.EmailKey, "eve@example.com"))
}
// The shell at root is served regardless of a root read grant.
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, eveReq(http.MethodGet, "/archive.html"))
if rec.Code != http.StatusOK {
t.Fatalf("GET /archive.html as non-root user: status=%d, want 200 (root tool shell is public); body=%s",
rec.Code, rec.Body.String())
}
// ...but data is still ACL-gated: eve cannot read a project she has no grant on.
rec2 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec2, eveReq(http.MethodGet, "/Project-A/"))
if rec2.Code != http.StatusForbidden {
t.Errorf("GET /Project-A/ as non-root user: status=%d, want 403 (data stays ACL-gated)", rec2.Code)
}
}
// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
// to the file API rather than to the read pipeline.
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*@example.com\": rwcd\n")
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
// Register the party (party_source: ssr).
mustMkdir(t, filepath.Join(root, "Project-A", "ssr"))
mustWrite(t, filepath.Join(root, "Project-A", "ssr", "Acme.yaml"), "kind: SSR\n")
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",
MaxWriteBytes: 1 << 20,
}
ring := handler.NewLogRing(10)
withEmail := func(req *http.Request, email string) *http.Request {
// dispatch reads email from context (ACLMiddleware would normally
// set it), so set it directly here.
return req.WithContext(handler.WithEmail(req.Context(), email))
}
// PUT a new file via dispatch. Content is party-scoped under
// archive/<party>/working/ — the project-level working/ aggregator is
// virtual (see TestFileAPI_MkdirInAggregatorRejected).
body := []byte("note body")
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/working/Acme/note.md", strings.NewReader(string(body))), "alice@example.com")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
}
// GET it back.
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
}
// MOVE it.
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
req.Header.Set("X-ZDDC-Op", "move")
req.Header.Set("X-ZDDC-Destination", "/Project-A/working/Acme/renamed.md")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
}
// DELETE it.
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/working/Acme/renamed.md", nil), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
}
// Reserved segment guard still applies to writes.
req = withEmail(httptest.NewRequest(http.MethodPut, "/.zddc.d/foo.txt", strings.NewReader("x")), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("PUT /.zddc.d/...: want 404, got %d", rec.Code)
}
}
// TestDispatchZddcWriteRouting pins the dispatcher's .zddc routing:
// GET/HEAD lands on ServeZddcFile (which serves the YAML view or the
// virtual placeholder), and PUT/DELETE/POST falls through past the
// dot-prefix guard into ServeFileAPI. Before the .zddc-leaf carve-out,
// PUT/DELETE 405'd at ServeZddcFile (or 404'd at the dot-prefix guard)
// and the YAML editor's save flow had no live path.
func TestDispatchZddcWriteRouting(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"admins:\n - admin@example.com\nacl:\n permissions:\n \"*@example.com\": r\n")
mustMkdir(t, filepath.Join(root, "Project-A"))
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",
MaxWriteBytes: 1 << 20,
}
ring := handler.NewLogRing(10)
withAuth := func(req *http.Request, email string, elevated bool) *http.Request {
ctx := handler.WithEmail(req.Context(), email)
ctx = handler.WithElevation(ctx, elevated)
return req.WithContext(ctx)
}
// GET routes to ServeZddcFile — serves YAML bytes for an authorised reader.
req := withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /.zddc: want 200, got %d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
t.Errorf("GET /.zddc Content-Type = %q, want application/yaml*", ct)
}
// PUT must route to ServeFileAPI (not 405 from ServeZddcFile).
body := []byte("admins:\n - admin@example.com\n - extra@example.com\n")
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader(body)), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("PUT /.zddc: want 200/201, got %d body=%s", rec.Code, rec.Body.String())
}
// Read back via GET to confirm the write landed.
req = withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if !strings.Contains(rec.Body.String(), "extra@example.com") {
t.Errorf("GET after PUT: body missing PUT bytes; got %q", rec.Body.String())
}
// Project-level .zddc that doesn't exist yet — PUT creates it.
req = withAuth(httptest.NewRequest(http.MethodPut, "/Project-A/.zddc", bytes.NewReader([]byte("title: A\n"))), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT /Project-A/.zddc: want 201, got %d body=%s", rec.Code, rec.Body.String())
}
// DELETE removes a .zddc.
req = withAuth(httptest.NewRequest(http.MethodDelete, "/Project-A/.zddc", nil), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE /Project-A/.zddc: want 204, got %d body=%s", rec.Code, rec.Body.String())
}
// Non-admin elevated still 403 on PUT — the carve-out only opens
// the path past the segment guard; the decider gates ActionAdmin.
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader([]byte("title: probe\n"))), "stranger@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("PUT /.zddc by stranger: want 403, got %d body=%s", rec.Code, rec.Body.String())
}
// The reserved .zddc.d/ sidecar is admin-only, but to an admin it's normal
// files — an elevated root admin can PUT into it.
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT /.zddc.d/something by admin: want 201, got %d body=%s", rec.Code, rec.Body.String())
}
// A non-admin (even elevated) is hard-denied on .zddc.d/ — the dispatch
// gate 404s it (existence-hidden) before the file API is reached, keeping
// the token store closed regardless of any operator ACL. (The file API's
// own authorizeAction 403 is the defense-in-depth layer for direct
// callers; see fileapi_test.go TestFileAPI_DotContentAllowedButZddcDReserved.)
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/evil", bytes.NewReader([]byte("x"))), "stranger@example.com", true)
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("PUT /.zddc.d/evil by stranger: want 404, got %d body=%s", rec.Code, rec.Body.String())
}
}
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 302'd
// to the canonical /<project>/.archive/... so all tracking-number references
// converge on a single stable URL per (project, tracking) regardless of the
// folder a relative "../.archive/..." link was resolved from.
func TestDispatchArchiveRedirect(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcd\n")
mustMkdir(t, filepath.Join(root, "ProjectA", "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",
}
ring := handler.NewLogRing(10)
cases := []struct {
name string
path string
query string
wantStatus int
wantLoc string
}{
{
"deep two segments",
"/ProjectA/Working/.archive/100.html",
"",
http.StatusFound,
"/ProjectA/.archive/100.html",
},
{
"deep three segments",
"/ProjectA/sub/sub2/.archive/100.html",
"",
http.StatusFound,
"/ProjectA/.archive/100.html",
},
{
"deep with trailing slash (listing)",
"/ProjectA/Working/.archive/",
"",
http.StatusFound,
"/ProjectA/.archive/",
},
{
"deep with query string preserved",
"/ProjectA/Working/.archive/100.html",
"v=42",
http.StatusFound,
"/ProjectA/.archive/100.html?v=42",
},
{
"already canonical (no redirect)",
"/ProjectA/.archive/100.html",
"",
// 100.html doesn't resolve in this index (no transmittal
// folders), so the handler 404s rather than redirecting.
http.StatusNotFound,
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rawURL := tc.path
if tc.query != "" {
rawURL += "?" + tc.query
}
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantLoc != "" {
if got := rec.Header().Get("Location"); got != tc.wantLoc {
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
}
}
})
}
}
func TestDispatchSlashRouting(t *testing.T) {
// Convention: <dir>/ → browse (directory view, via DirTool which
// defaults to browse); <dir> → the directory's default_tool ("the
// specialized app": browse under working/+reviewing/, transmittal
// under staging/, archive under archive/, tables under
// archive/<party>/mdl). Without a default_tool, no-slash falls
// through to the trailing-slash redirect (302).
//
// The only trailing-slash redirect is for a directory that is the
// rows-dir of a table declared via a REAL on-disk parent .zddc
// `tables:` map with an existing *.table.yaml spec — it bounces to
// <parent>/<name>.table.html. The default-MDL virtual fallback at
// archive/<party>/mdl/ does NOT redirect: the slash form there shows
// the browse listing of the row YAMLs (the no-slash mdl form serves
// the table view).
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcda\n")
for _, sub := range []string{
"Project/working",
"Project/staging",
"Project/archive",
"Project/archive/Acme",
"Project/archive/Acme/incoming",
"Project/archive/Acme/mdl",
"Project/scratch",
} {
mustMkdir(t, filepath.Join(root, sub))
}
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)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
cases := []struct {
name string
path string
wantStatus int
wantNoRedirect bool
wantLoc string // checked when wantStatus is a redirect
}{
{"working no-slash → browse", "/Project/working", http.StatusOK, true, ""},
{"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
{"archive no-slash → archive", "/Project/archive", http.StatusOK, true, ""},
{"archive slash → browse", "/Project/archive/", http.StatusOK, true, ""},
{"archive/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true, ""},
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true, ""},
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true, ""},
// The default-MDL virtual fallback does NOT redirect the slash
// form — it shows the browse listing of the row YAMLs. (Only a
// real on-disk parent .zddc tables: + *.table.yaml triggers the
// bounce to <parent>/<name>.table.html.)
{"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true, ""},
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
{"non-canonical no-slash → 302 to slash", "/Project/scratch", http.StatusFound, false, ""},
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
// Project root no-slash → synthetic landing page (handler.ServeProjectLanding).
{"project root no-slash → landing", "/Project", http.StatusOK, true, ""},
}
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, appsSrv, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
t.Errorf("path=%q unexpected redirect to %q",
tc.path, rec.Header().Get("Location"))
}
if tc.wantLoc != "" {
if got := rec.Header().Get("Location"); got != tc.wantLoc {
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
}
}
})
}
}
// Canonical project-root folders (archive/working/staging/reviewing)
// that don't yet exist on disk must render as 200 + empty listing
// rather than 404, so the stage-strip nav lands on a usable view on a
// fresh project. Mirror of fs.ListDirectory's read-side fallback at
// the dispatcher level — without this, os.Stat 404s before
// ServeDirectory ever runs.
func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcda\n")
// Project exists, but NO subdirs (no archive/, working/, staging/,
// reviewing/). Fresh-project state.
mustMkdir(t, filepath.Join(root, "Project"))
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)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// JSON request → 200 + JSON array (not null, not 404).
// Virtual user-home injection at <project>/working/ depends on a
// context-bound email; this test calls dispatch directly without
// the ACL middleware that sets that context, so email is "" here
// and working/ also returns []. virtualUserHomeEntry's email
// branch is covered separately by tests in internal/fs/tree_test.go.
for _, stage := range []string{"archive", "working", "staging", "reviewing"} {
t.Run("json/"+stage, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := strings.TrimSpace(rec.Body.String())
if body != "[]" {
t.Errorf("%s/ body=%q, want %q", stage, body, "[]")
}
})
}
// No-trailing-slash form on a canonical peer → its default app.
// In the flat-peer layout these are physical peers: working/reviewing
// default to browse, staging to transmittal, archive to the archive
// tool.
noSlashDefaultApp := []struct {
stage string
expect string // substring that should appear in the response body
}{
{"working", "ZDDC Browse"},
{"staging", "ZDDC Transmittal"},
{"archive", "ZDDC Archive"},
{"reviewing", "ZDDC Browse"},
}
for _, tc := range noSlashDefaultApp {
t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+tc.stage, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), tc.expect) {
t.Errorf("%s/ body missing %q", tc.stage, tc.expect)
}
})
}
// Trailing-slash form on a canonical folder serves the browse
// app for HTML requests — same convention as the existing IsDir
// branch. The slash-vs-no-slash distinction is the user's signal:
// "show me the directory contents" vs "open the default tool".
for _, stage := range []string{"working", "staging", "archive", "reviewing"} {
t.Run("slash/"+stage+" → browse", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
req.Header.Set("Accept", "text/html")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "ZDDC Browse") {
t.Errorf("%s/ HTML response missing 'ZDDC Browse'", stage)
}
})
}
// Non-canonical missing folder still 404s (the fallback is
// scoped to the four canonical names, not a blanket "missing →
// empty" rule).
t.Run("non-canonical missing → 404", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/random-folder/", nil)
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
})
}
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
// API's write path, so a write to an archive URL never silently mutates
// anything (and so a 302 redirect can never silently downgrade a write
// to a GET on the canonical URL).
func TestDispatchArchiveMethodGate(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcd\n")
mustMkdir(t, filepath.Join(root, "ProjectA"))
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",
MaxWriteBytes: 1 << 20,
}
ring := handler.NewLogRing(10)
for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
for _, path := range []string{
"/ProjectA/.archive/100.html",
"/ProjectA/Working/.archive/100.html",
} {
t.Run(method+" "+path, func(t *testing.T) {
req := httptest.NewRequest(method, path, strings.NewReader("body"))
req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String())
}
if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "GET") {
t.Errorf("%s %s: Allow=%q, want to contain GET", method, path, allow)
}
})
}
}
}
// TestDispatchCaseInsensitiveURL: mixed-case URLs resolve to the on-disk
// canonical case, with the lowercase variant winning when both case
// variants exist as siblings on disk.
func TestDispatchCaseInsensitiveURL(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcd\n")
mustMkdir(t, filepath.Join(root, "project-a", "working"))
mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note")
// Sibling Mixed-Case dir present too. Lowercase must win on the
// case-insensitive resolution; the Mixed-Case dir's contents must
// not bleed through under any URL casing.
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
mustWrite(t, filepath.Join(root, "Project-A", "Working", "note.md"), "MIXEDCASE note")
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
url string
}{
{"all lowercase", "/project-a/working/note.md"},
{"mixed case top", "/Project-A/working/note.md"},
{"mixed case nested", "/PROJECT-A/Working/Note.md"},
{"all uppercase", "/PROJECT-A/WORKING/NOTE.MD"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "lowercase note" {
t.Errorf("body=%q want %q (lowercase variant must win)",
got, "lowercase note")
}
})
}
}
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 mustWriteZip(t *testing.T, path string, entries map[string]string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir for zip %s: %v", path, err)
}
f, err := os.Create(path)
if err != nil {
t.Fatalf("create zip %s: %v", path, err)
}
defer f.Close()
zw := zip.NewWriter(f)
for name, body := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip.Create(%q): %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("zip write %q: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close %s: %v", path, err)
}
}
// TestDispatchZipRouting exercises the .zip-as-virtual-directory
// intercept: <…>.zip/ lists members, <…>.zip/member streams one
// member, bare <…>.zip is still a plain file download, writes into a
// zip are refused, and ACL is inherited from the directory containing
// the zip (a zip has no .zddc of its own — same as .archive).
func TestDispatchZipRouting(t *testing.T) {
root := t.TempDir()
// Only alice@x may read under staging/; bob@x is denied there.
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": r\n")
mustMkdir(t, filepath.Join(root, "Proj", "staging"))
mustWrite(t, filepath.Join(root, "Proj", "staging", ".zddc"),
"acl:\n inherit: false\n permissions:\n \"alice@x\": rwcda\n")
zipPath := filepath.Join(root, "Proj", "staging", "T.zip")
mustWriteZip(t, zipPath, map[string]string{
"DOC-001.pdf": "PDFDATA",
"sub/note.txt": "a note",
})
zipBytes, _ := os.ReadFile(zipPath)
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)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
do := func(method, path, email string, hdr map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, email))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
return rec
}
t.Run("listing JSON", func(t *testing.T) {
rec := do(http.MethodGet, "/Proj/staging/T.zip/", "alice@x", map[string]string{"Accept": "application/json"})
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var fis []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &fis); err != nil {
t.Fatalf("decode listing: %v; body=%s", err, rec.Body.String())
}
names := map[string]bool{}
for _, fi := range fis {
names[fi["name"].(string)] = fi["is_dir"] == true
}
if d, ok := names["DOC-001.pdf"]; !ok || d {
t.Errorf("expected file DOC-001.pdf; got %v", names)
}
if d, ok := names["sub/"]; !ok || !d {
t.Errorf("expected dir sub/; got %v", names)
}
})
t.Run("member extracted", func(t *testing.T) {
rec := do(http.MethodGet, "/Proj/staging/T.zip/sub/note.txt", "alice@x", nil)
if rec.Code != http.StatusOK || rec.Body.String() != "a note" {
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("X-ZDDC-Source") != "zip:T.zip" {
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
}
})
t.Run("bare .zip is a plain download", func(t *testing.T) {
rec := do(http.MethodGet, "/Proj/staging/T.zip", "alice@x", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
if rec.Body.Len() != len(zipBytes) {
t.Errorf("bare .zip body len=%d, want %d (raw zip bytes)", rec.Body.Len(), len(zipBytes))
}
// It must NOT have the zip-virtual-dir source header.
if rec.Header().Get("X-ZDDC-Source") == "zip:T.zip" {
t.Errorf("bare .zip should be served as a file, not the virtual-dir handler")
}
})
t.Run("write into zip refused", func(t *testing.T) {
rec := do(http.MethodPut, "/Proj/staging/T.zip/new.txt", "alice@x", nil)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("PUT into zip status=%d, want 405", rec.Code)
}
})
t.Run("ACL inherited from containing dir — denied", func(t *testing.T) {
rec := do(http.MethodGet, "/Proj/staging/T.zip/sub/note.txt", "bob@x", nil)
if rec.Code != http.StatusForbidden {
t.Errorf("bob denied under staging/ → zip member status=%d, want 403", rec.Code)
}
rec2 := do(http.MethodGet, "/Proj/staging/T.zip/", "bob@x", map[string]string{"Accept": "application/json"})
if rec2.Code != http.StatusForbidden {
t.Errorf("bob denied → zip listing status=%d, want 403", rec2.Code)
}
})
t.Run("missing member 404", func(t *testing.T) {
rec := do(http.MethodGet, "/Proj/staging/T.zip/no/such.txt", "alice@x", nil)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
})
t.Run("directory member 302 to slash", func(t *testing.T) {
rec := do(http.MethodGet, "/Proj/staging/T.zip/sub", "alice@x", nil)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/Proj/staging/T.zip/sub/" {
t.Errorf("status=%d loc=%q", rec.Code, rec.Header().Get("Location"))
}
})
}
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)
}
}
// TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper
// behavior we wire in main(): responses above MinSize get gzip-encoded
// when the client advertises Accept-Encoding: gzip; small responses
// pass through uncompressed; HEAD requests still set Vary correctly.
//
// We construct the wrapper the same way main() does (1024 byte minsize)
// and exercise it against a tiny test handler — full end-to-end is
// covered by the live curl smoke test in CI / dev verification.
func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
// Re-create the wrapper config from main.go so this test stays in
// sync with the real wiring.
wrapper, err := newGzipWrapper()
if err != nil {
t.Fatalf("newGzipWrapper: %v", err)
}
largeBody := strings.Repeat("ZDDC ", 4000) // ~20 KB, well over MinSize
smallBody := "ok"
handler := wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.URL.Path == "/large" {
_, _ = w.Write([]byte(largeBody))
} else {
_, _ = w.Write([]byte(smallBody))
}
}))
srv := httptest.NewServer(handler)
defer srv.Close()
t.Run("large body with Accept-Encoding gzip → compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
req.Header.Set("Accept-Encoding", "gzip")
// Disable transparent decompression so we can read the raw bytes
// and confirm the wire format.
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Errorf("Content-Encoding = %q, want gzip", got)
}
if got := resp.Header.Get("Vary"); !strings.Contains(strings.ToLower(got), "accept-encoding") {
t.Errorf("Vary = %q, want to contain Accept-Encoding", got)
}
})
t.Run("small body → not compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/small", nil)
req.Header.Set("Accept-Encoding", "gzip")
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got == "gzip" {
t.Errorf("Content-Encoding = gzip; small response should not be compressed")
}
})
t.Run("no Accept-Encoding → not compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q; client without Accept-Encoding should get plain", got)
}
})
}
// (TestDispatchHistoryReadCarveOut was removed: history snapshots now live
// under the reserved .zddc.d/history/ namespace — blocked raw by the
// dot-prefix guard, like any bookkeeping, and surfaced only through the
// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments;
// the viewer is covered in mdhistory_test.go.)
// writeRootBundle writes <root>/.zddc.zip containing the given members.
// Used by dispatch tests exercising the local tool-HTML bundle override.
func writeRootBundle(t *testing.T, root string, members map[string]string) {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, body := range members {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := os.WriteFile(filepath.Join(root, ".zddc.zip"), buf.Bytes(), 0o644); err != nil {
t.Fatalf("write bundle: %v", err)
}
}