ZDDC/zddc/cmd/zddc-server/main_test.go
ZDDC fcb8fc6cf1 feat(server): edit-in-place for the .zddc.zip config bundle, with in-zip history
A zip is random-access (unlike a streamed .tgz), so a member can be rewritten
in place. ServeZipWrite (handler/zipwrite.go) handles PUT (write/create a
member) and DELETE (remove) inside the .zddc.zip bundle: read the whole archive,
snapshot the prior member into an in-zip .history/<member>/<ts> + append a
log.jsonl audit line, mutate, then write a fresh zip and atomically rename over
the original (serialized on one mutex). After a write the policy cache is
invalidated so .zddc policy members take effect immediately, and the apps.Bundle
mtime-reload picks up tool-HTML edits.

Gated to active admins and to the .zddc.zip bundle only (dispatch's bundle gate
already 404s everyone else; content zips — transmittal/WORM packages — stay
read-only and 405). Writing into the in-zip .history/ is refused (append-only).

Also fixes a read collision: a .zddc member INSIDE a zip (e.g. a policy member,
URL ".../.zddc.zip/<dir>/.zddc") was being grabbed by the raw-.zddc-view handler
and 500ing; that handler now excludes ".zip/" paths so the zip intercept serves
the member.

Tests: writer round-trip (incl. wildcard member); dispatch create+overwrite,
policy-takes-effect, in-zip history audit, read-back, non-admin 404, content-zip
405.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:28:06 -05:00

1266 lines
50 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)
}
}
// TestDispatchFileToFormView locks in the views.file → form shape: a browser
// NAVIGATION (Accept: text/html) to a no-slash data file, in a dir whose
// cascade declares views.file = {tool: form}, serves the form editor bound to
// that file — while a programmatic fetch (Accept: */*, the tables client) and
// an explicit ?raw still get raw bytes, so row fetching never breaks. A dir
// without views.file keeps serving raw bytes on navigation too.
func TestDispatchFileToFormView(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcd\n")
dir := filepath.Join(root, "Proj", "records")
mustMkdir(t, dir)
// views.file declared on the records dir → form editor for its files.
mustWrite(t, filepath.Join(dir, ".zddc"),
"views:\n file:\n tool: form\n")
// Form schema lives in the supporting-files reserve.
mustMkdir(t, filepath.Join(dir, ".zddc.d"))
mustWrite(t, filepath.Join(dir, ".zddc.d", "form.yaml"),
"schema:\n type: object\n properties:\n title:\n type: string\n")
mustWrite(t, filepath.Join(dir, "rec1.yaml"), "title: Hello\n")
// A sibling file in a dir WITHOUT views.file stays raw.
mustWrite(t, filepath.Join(root, "Proj", "plain.yaml"), "x: 1\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"}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
do := func(path string, hdr map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
return rec
}
// Navigation → form editor HTML.
rec := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "text/html"})
if rec.Code != http.StatusOK {
t.Fatalf("navigation: status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("navigation Content-Type=%q, want text/html (form)", ct)
}
if strings.Contains(rec.Body.String(), "title: Hello") {
t.Errorf("navigation served raw YAML, want the form editor")
}
// Programmatic fetch (Accept: */*) → raw YAML bytes.
rec2 := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "*/*"})
if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), "title: Hello") {
t.Errorf("fetch: status=%d body=%q, want raw YAML", rec2.Code, rec2.Body.String())
}
// ?raw escape hatch → raw bytes even for a browser.
rec3 := do("/Proj/records/rec1.yaml?raw=1", map[string]string{"Accept": "text/html"})
if !strings.Contains(rec3.Body.String(), "title: Hello") {
t.Errorf("?raw body=%q, want raw YAML", rec3.Body.String())
}
// No views.file declared → navigation still serves raw bytes.
rec4 := do("/Proj/plain.yaml", map[string]string{"Accept": "text/html"})
if !strings.Contains(rec4.Body.String(), "x: 1") {
t.Errorf("no-views file body=%q, want raw YAML", rec4.Body.String())
}
}
// TestDispatchBundleAdminView locks in admin-mode visibility of the site-root
// .zddc.zip config bundle: an active (elevated) admin may browse it as a zip
// directory (list members, extract a member) and download it, while everyone
// else — including the same admin un-elevated, and non-admins — gets 404 for
// every bundle URL shape (closing the previous by-name member read).
func TestDispatchBundleAdminView(t *testing.T) {
root := t.TempDir()
// alice is a root admin; bob is a plain reader.
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"alice@x\": rwcda\n \"bob@x\": r\nadmins:\n - alice@x\n")
writeRootBundle(t, root, map[string]string{
"archive.html": "<!doctype html>BUNDLE archive",
"sub/note.txt": "a member 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)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
do := func(path, email string, elevated bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
ctx := context.WithValue(req.Context(), handler.EmailKey, email)
ctx = context.WithValue(ctx, handler.ElevatedKey, elevated)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
return rec
}
// Elevated admin: member listing, member extract, and bare download all work.
if rec := do("/.zddc.zip/", "alice@x", true); rec.Code != http.StatusOK {
t.Errorf("admin GET /.zddc.zip/ : status=%d body=%s, want 200 listing", rec.Code, rec.Body.String())
}
if rec := do("/.zddc.zip/archive.html", "alice@x", true); rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), "BUNDLE archive") {
t.Errorf("admin GET member: status=%d body=%s, want 200 member bytes", rec.Code, rec.Body.String())
}
if rec := do("/.zddc.zip", "alice@x", true); rec.Code != http.StatusOK {
t.Errorf("admin GET bare /.zddc.zip : status=%d, want 200 download", rec.Code)
}
// Same admin un-elevated → 404 (sudo model: powers are per-request).
if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusNotFound {
t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
}
// Non-admin reader → 404 for listing AND by-name member (no leak).
if rec := do("/.zddc.zip/", "bob@x", true); rec.Code != http.StatusNotFound {
t.Errorf("non-admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
}
if rec := do("/.zddc.zip/archive.html", "bob@x", true); rec.Code != http.StatusNotFound {
t.Errorf("non-admin GET member: status=%d, want 404 (no by-name leak)", rec.Code)
}
}
// TestDispatchBundleAdminWrite locks in edit-in-place for the .zddc.zip config
// bundle: an active admin can PUT/DELETE members (changing live policy), each
// edit snapshots the prior version into an in-zip .history/, non-admins get 404
// (the bundle gate), and content zips stay read-only.
func TestDispatchBundleAdminWrite(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"alice@x\": rwcda\nadmins:\n - alice@x\n")
mustMkdir(t, filepath.Join(root, "Proj"))
writeRootBundle(t, root, map[string]string{"browse.html": "<!doctype html>BUNDLE"})
mustWriteZip(t, filepath.Join(root, "Proj", "Foo.zip"), map[string]string{"m.txt": "x"})
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: 64 * 1024}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
do := func(method, path, email string, elevated bool, body []byte) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, path, nil)
}
ctx := context.WithValue(req.Context(), handler.EmailKey, email)
ctx = context.WithValue(ctx, handler.ElevatedKey, elevated)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
return rec
}
// 1. Admin creates a policy member (governs the project level via "*").
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", true,
[]byte("acl:\n permissions:\n \"team@x\": rwc\n")); rec.Code != http.StatusCreated {
t.Fatalf("PUT new member: status=%d body=%s, want 201", rec.Code, rec.Body.String())
}
// 2. The edit took effect on the live cascade (write invalidated the cache).
zddc.InvalidateCache(root)
chain, _ := zddc.EffectivePolicy(root, filepath.Join(root, "Proj"))
if !zddc.EffectiveVerbs(chain, "team@x").Has(zddc.VerbC) {
t.Errorf("bundle policy edit didn't reach the cascade: team@x lacks create at /Proj")
}
// 3. Edit again (existing member → snapshots to .history/).
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "alice@x", true,
[]byte("acl:\n permissions:\n \"team@x\": r\n")); rec.Code != http.StatusOK {
t.Fatalf("PUT overwrite: status=%d, want 200", rec.Code)
}
// 4. Read back the current member.
if rec := do(http.MethodGet, "/.zddc.zip/Proj/.zddc", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "\"team@x\": r") {
t.Errorf("read-back body=%q, want the latest edit", rec.Body.String())
}
// 5. The in-zip history log records the edit (audited with the editor email).
if rec := do(http.MethodGet, "/.zddc.zip/.history/Proj/.zddc/log.jsonl", "alice@x", true, nil); !strings.Contains(rec.Body.String(), "alice@x") {
t.Errorf("history log=%q, want an alice@x entry", rec.Body.String())
}
// 6. Non-admin write → 404 (bundle existence-hidden to non-admins).
if rec := do(http.MethodPut, "/.zddc.zip/Proj/.zddc", "bob@x", true, []byte("x")); rec.Code != http.StatusNotFound {
t.Errorf("non-admin PUT: status=%d, want 404", rec.Code)
}
// 7. Content zips stay read-only — even for an admin.
if rec := do(http.MethodPut, "/Proj/Foo.zip/m.txt", "alice@x", true, []byte("y")); rec.Code != http.StatusMethodNotAllowed {
t.Errorf("content-zip PUT: status=%d, want 405", rec.Code)
}
}