ZDDC/zddc/internal/handler/fileapi_test.go
ZDDC 2d114fcb96 refactor: unified listing protocol + form-editor retirement + admin elevation
Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.

== Listing protocol ==

GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:

- listing.FileInfo gains an optional `title` field (read from each
  directory's own .zddc title:). Generic clients (landing, browse)
  read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
  virtual:true) when no on-disk file exists at that path and the
  caller asked for ?hidden=1. Opens an editable view of the cascade
  defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
  The bare-root landing serve is Accept-gated: HTML requests get the
  landing tool (project picker), JSON requests fall through to
  ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
  strip trailing slash) — same pattern fetchParties already used at
  /<project>/archive/.

== Form editor retirement ==

`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.

- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
  opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
  browse URL instead of the dead form.

== Admin elevation (Principal model) ==

Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.

- zddc.Principal{Email, Elevated} replaces bare-email arguments on
  IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
  the elevation gate compiler-enforced at every admin call site —
  audit-fragility is gone. The empty-email short-circuit is no longer
  load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
  are implicitly elevated (CLI clients can't toggle a cookie); browser
  sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
  is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
  bypasses elevation with a synthetic-elevated Principal — different
  cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
  have admin authority anywhere?") so the header toggle can render
  itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
  for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
  URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
  pre-elevation default; tests for the un-elevated gate use the
  explicit form).

Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:15:07 -05:00

812 lines
29 KiB
Go

package handler
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// fileAPITestSetup writes a tree of directories and seed files under a
// temp root and returns a do() helper that builds and runs file API
// requests. The root .zddc grants caller@example.com read+write across
// the tree (single ACL allows both — the internal decider doesn't split
// read/write yet).
//
// seed: relative path → bytes (created as a regular file).
// dirs: relative paths to mkdir.
func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
t.Helper()
root = t.TempDir()
// Root .zddc grants writer access to *@example.com.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n allow:\n - \"*@example.com\"\n deny: []\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
for _, d := range dirs {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
for rel, body := range seed {
full := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatalf("seed %s: %v", rel, err)
}
}
zddc.InvalidateCache(root)
cfg = config.Config{
Root: root,
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1024 * 1024,
}
do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, target, nil)
}
for k, v := range headers {
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
return cfg, do, root
}
func sha32(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:32]
}
func TestFileAPI_PutCreatesFile(t *testing.T) {
_, do, root := fileAPITestSetup(t, []string{"Incoming"}, nil)
body := []byte("hello world")
rec := do(http.MethodPut, "/Incoming/note.txt", "alice@example.com", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
}
got, err := os.ReadFile(filepath.Join(root, "Incoming/note.txt"))
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != "hello world" {
t.Fatalf("body mismatch: %q", got)
}
wantTag := `"` + sha32(body) + `"`
if got := rec.Header().Get("ETag"); got != wantTag {
t.Fatalf("ETag: want %s, got %s", wantTag, got)
}
}
func TestFileAPI_PutOverwritesExisting(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, map[string]string{
"Incoming/old.txt": "first",
})
body := []byte("second")
rec := do(http.MethodPut, "/Incoming/old.txt", "alice@example.com", body, nil)
if rec.Code != http.StatusOK {
t.Fatalf("want 200 (overwrite), got %d: %s", rec.Code, rec.Body.String())
}
got, _ := os.ReadFile(filepath.Join(root, "Incoming/old.txt"))
if string(got) != "second" {
t.Fatalf("body: %q", got)
}
}
func TestFileAPI_PutAutoCreatesParents(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
rec := do(http.MethodPut, "/Incoming/sub/deep/x.bin", "alice@example.com", []byte("data"), nil)
if rec.Code != http.StatusCreated {
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Incoming/sub/deep/x.bin")); err != nil {
t.Fatalf("stat: %v", err)
}
}
func TestFileAPI_PutDenyForbidden(t *testing.T) {
cfg, do, _ := fileAPITestSetup(t, []string{"Working"}, nil)
// Tighten ACL to a different domain — alice@example.com no longer
// matches and writes must be 403.
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
[]byte("acl:\n allow:\n - \"*@allowed.com\"\n deny: []\n"), 0o644); err != nil {
t.Fatalf("rewrite .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
rec := do(http.MethodPut, "/Working/note.md", "alice@example.com", []byte("nope"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, nil)
for _, p := range []string{"/.zddc", "/foo/.hidden", "/_app/spoof.html", "/_template/x"} {
rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 for %s, got %d", p, rec.Code)
}
}
}
func TestFileAPI_PutOversizeRejected(t *testing.T) {
cfg, _, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
cfg.MaxWriteBytes = 16
body := bytes.Repeat([]byte("A"), 32)
req := httptest.NewRequest(http.MethodPut, "/Incoming/big.bin", bytes.NewReader(body))
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("want 413, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_PutTrailingSlashRejected(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, nil)
rec := do(http.MethodPut, "/Incoming/", "alice@example.com", []byte("x"), nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}
func TestFileAPI_DeleteRemovesFile(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, map[string]string{
"Incoming/old.txt": "garbage",
})
rec := do(http.MethodDelete, "/Incoming/old.txt", "alice@example.com", nil, nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Incoming/old.txt")); !os.IsNotExist(err) {
t.Fatalf("file should be gone, err=%v", err)
}
}
func TestFileAPI_DeleteMissing404(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, nil)
rec := do(http.MethodDelete, "/Incoming/never-existed.txt", "alice@example.com", nil, nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rec.Code)
}
}
func TestFileAPI_DeleteDirectoryConflict(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming/sub"}, nil)
rec := do(http.MethodDelete, "/Incoming/sub", "alice@example.com", nil, nil)
if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_MoveRenames(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, map[string]string{
"Incoming/old.pdf": "PDF body",
})
rec := do(http.MethodPost, "/Incoming/old.pdf", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
"X-ZDDC-Destination": "/Incoming/new.pdf",
})
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Incoming/old.pdf")); !os.IsNotExist(err) {
t.Fatalf("source still exists")
}
got, err := os.ReadFile(filepath.Join(root, "Incoming/new.pdf"))
if err != nil {
t.Fatalf("read dest: %v", err)
}
if string(got) != "PDF body" {
t.Fatalf("dest bytes: %q", got)
}
if dst := rec.Header().Get("X-ZDDC-Destination"); dst != "/Incoming/new.pdf" {
t.Fatalf("destination header: %s", dst)
}
}
func TestFileAPI_MoveDestinationExistsConflict(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
"Incoming/a.txt": "a",
"Incoming/b.txt": "b",
})
rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
"X-ZDDC-Destination": "/Incoming/b.txt",
})
if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_MoveMissingDestinationHeader(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
"Incoming/a.txt": "a",
})
rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_MoveCreatesParentDirs(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, map[string]string{
"Incoming/a.txt": "hi",
})
rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
"X-ZDDC-Destination": "/Working/sub/a.txt",
})
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Working/sub/a.txt")); err != nil {
t.Fatalf("dest not present: %v", err)
}
}
func TestFileAPI_PostUnknownOp(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
rec := do(http.MethodPost, "/Incoming/x.txt", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "weld",
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}
func TestFileAPI_PostMissingOp(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
rec := do(http.MethodPost, "/Incoming/x.txt", "alice@example.com", nil, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}
func TestFileAPI_MkdirCreates(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
rec := do(http.MethodPost, "/Incoming/newfolder/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
}
info, err := os.Stat(filepath.Join(root, "Incoming/newfolder"))
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.IsDir() {
t.Fatalf("not a dir")
}
}
func TestFileAPI_MkdirIdempotent(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming/exists"}, nil)
rec := do(http.MethodPost, "/Incoming/exists/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rec.Code)
}
}
func TestFileAPI_IfMatchEnforced(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
"Incoming/x.txt": "v1",
})
// Wrong ETag → 412.
rec := do(http.MethodPut, "/Incoming/x.txt", "alice@example.com", []byte("v2"), map[string]string{
"If-Match": `"` + strings.Repeat("0", 32) + `"`,
})
if rec.Code != http.StatusPreconditionFailed {
t.Fatalf("want 412, got %d", rec.Code)
}
// Correct ETag → 200.
correctTag := sha32([]byte("v1"))
rec = do(http.MethodPut, "/Incoming/x.txt", "alice@example.com", []byte("v2"), map[string]string{
"If-Match": `"` + correctTag + `"`,
})
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_IfMatchWildcardOnMissing(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
rec := do(http.MethodPut, "/Incoming/new.txt", "alice@example.com", []byte("data"), map[string]string{
"If-Match": `*`,
})
if rec.Code != http.StatusPreconditionFailed {
t.Fatalf("want 412 (wildcard expects existing), got %d", rec.Code)
}
}
func TestFileAPI_PathTraversalBlocked(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, nil)
rec := do(http.MethodPut, "/../escaped.txt", "alice@example.com", []byte("x"), nil)
if rec.Code != http.StatusNotFound && rec.Code != http.StatusBadRequest {
t.Fatalf("traversal not blocked: %d", rec.Code)
}
}
func TestFileAPI_AnonymousDenied(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
rec := do(http.MethodPut, "/Incoming/note.txt", "", []byte("x"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403 for anon, got %d", rec.Code)
}
}
// rolePermissionsTestSetup creates a project + per-party exchange shape:
//
// root .zddc: _company:r, _doc_controller:rwcda
// <project>/archive/Acme/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:""
// roles defined at root.
//
// The project is "Project-X"; the counterparty is "Acme". URLs target
// paths like /Project-X/archive/Acme/incoming/<file>.
//
// Returns the same do() helper as fileAPITestSetup.
func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
t.Helper()
root = t.TempDir()
// Root .zddc — company gets r, doc_controller gets rwcda. Roles
// defined here so the per-party subtree's permissions can reference
// them by name.
rootZ := []byte(`roles:
_company:
members: ["*@mycompany.com"]
_doc_controller:
members: [dc@mycompany.com]
vendor_acme:
members: ["*@acme.com"]
acl:
permissions:
_company: r
_doc_controller: rwcda
`)
if err := os.WriteFile(filepath.Join(root, ".zddc"), rootZ, 0o644); err != nil {
t.Fatalf("root .zddc: %v", err)
}
// Project + per-party canonical layout.
partyDir := filepath.Join(root, "Project-X", "archive", "Acme")
for _, sub := range []string{"incoming", "issued", "received"} {
if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil {
t.Fatalf("mkdir party/%s: %v", sub, err)
}
}
partyZ := []byte(`acl:
permissions:
vendor_acme: rwcd
_doc_controller: rwcda
_company: ""
`)
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil {
t.Fatalf("party .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg = config.Config{
Root: root,
EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1024 * 1024,
CascadeMode: "delegated",
}
decider := &policy.InternalDecider{}
do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, target, nil)
}
for k, v := range headers {
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
ctx = context.WithValue(ctx, DeciderKey, decider)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
return cfg, do, root
}
func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
_, do, _ := rolePermissionsTestSetup(t)
// Vendor PUTs into their incoming → 201.
rec := do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
}
// Vendor overwrites the same file → 200 (rwcd has w).
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
if rec.Code != http.StatusOK {
t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code)
}
}
func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) {
_, do, root := rolePermissionsTestSetup(t)
// Seed an existing issued file.
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// Vendor cannot overwrite — ancestor grant masked to r in issued.
rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("PUT vendor → issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String())
}
// Vendor cannot delete.
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", nil, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("DELETE vendor → issued: want 403, got %d", rec.Code)
}
// Vendor cannot create new files — they have no explicit .zddc grant
// at the issued folder, so the WORM split reduces their inherited
// rwcd to r-only.
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/new.pdf", "rep@acme.com", []byte("x"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("PUT vendor → issued (create): want 403 (no explicit grant at issued), got %d", rec.Code)
}
}
func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) {
_, do, root := rolePermissionsTestSetup(t)
// Without a .zddc at archive/Acme/issued/ explicitly granting cr,
// the dc's inherited rwcda is masked to r. They cannot create.
rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("dc without explicit grant → issued: want 403, got %d: %s", rec.Code, rec.Body.String())
}
// Operator names the document-controller role in the issued/ WORM
// zone. That role's members then get {r, c} there — the embedded
// `worm: []` (no controllers) is unioned with this deeper grant.
issuedZ := []byte("worm:\n - _doc_controller\n")
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
t.Fatalf("write issued .zddc: %v", err)
}
zddc.InvalidateCache(root)
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
if rec.Code != http.StatusCreated {
t.Fatalf("dc with explicit grant → issued: want 201, got %d: %s", rec.Code, rec.Body.String())
}
got, _ := os.ReadFile(filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2-spec.pdf"))
if string(got) != "CONTROLLED" {
t.Fatalf("body: %q", got)
}
// dc still cannot overwrite — explicit grant is cr, no w.
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("dc PUT overwrite → issued: want 403, got %d", rec.Code)
}
// dc still cannot delete.
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("dc DELETE → issued: want 403, got %d", rec.Code)
}
}
func TestFileAPI_WORM_AdminBypass(t *testing.T) {
cfg, do, root := rolePermissionsTestSetup(t)
// Promote root@example.com to root admin.
rootZ, _ := os.ReadFile(filepath.Join(cfg.Root, ".zddc"))
updated := string(rootZ) + "\nadmins:\n - root@example.com\n"
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte(updated), 0o644); err != nil {
t.Fatalf("rewrite root .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
// Seed an issued file and have root@ delete it (escape hatch).
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/mistake.pdf"), []byte("oops"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
rec := do(http.MethodDelete, "/Project-X/archive/Acme/issued/mistake.pdf", "root@example.com", nil, nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("admin DELETE → issued: want 204, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
_, do, root := rolePermissionsTestSetup(t)
// Vendor creates a folder under their incoming. Server should
// auto-write a .zddc granting them rwcda on the new subtree.
rec := do(http.MethodPost, "/Project-X/archive/Acme/incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
}
autoZ := filepath.Join(root, "Project-X/archive/Acme/incoming/2026-05-15-issue/.zddc")
data, err := os.ReadFile(autoZ)
if err != nil {
t.Fatalf("auto .zddc not written: %v", err)
}
body := string(data)
if !strings.Contains(body, "created_by: rep@acme.com") {
t.Errorf("auto .zddc missing created_by: %s", body)
}
if !strings.Contains(body, "rep@acme.com: rwcda") {
t.Errorf("auto .zddc missing email→rwcda grant: %s", body)
}
// Now the cascade caches are stale because we didn't go through
// WriteFile here; the server's writeAutoOwnZddc DID call WriteFile
// (via zddc.WriteFile → InvalidateCache). Confirm the vendor can
// now PUT a brand-new file inside their owned folder where they
// otherwise wouldn't have ACL admin rights.
zddc.InvalidateCache(root)
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
if rec.Code != http.StatusCreated {
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
_, do, root := rolePermissionsTestSetup(t)
// Name the document-controller role in the issued/ WORM zone so its
// members get cr there.
issuedZ := []byte("worm:\n - _doc_controller\n")
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
t.Fatalf("seed issued .zddc: %v", err)
}
zddc.InvalidateCache(root)
// Doc controller mkdir under issued — should succeed (cr survives the
// WORM mask) but should NOT auto-write an ownership .zddc (issued is
// not declared auto_own in the cascade).
rec := do(http.MethodPost, "/Project-X/archive/Acme/issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
}
autoZ := filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2/.zddc")
if _, err := os.Stat(autoZ); !os.IsNotExist(err) {
t.Errorf("auto .zddc should NOT be written under issued; got err=%v", err)
}
}
func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) {
cfg, _, root := rolePermissionsTestSetup(t)
cfg.CascadeMode = "strict"
// Add a strict-mode lockout at root: deny vendor_acme everywhere.
rootZ, _ := os.ReadFile(filepath.Join(root, ".zddc"))
updated := strings.Replace(string(rootZ), "_doc_controller: rwcda\n",
"_doc_controller: rwcda\n vendor_acme: \"\"\n", 1)
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(updated), 0o644); err != nil {
t.Fatalf("rewrite root: %v", err)
}
zddc.InvalidateCache(root)
// Build a strict-mode decider so the file API uses the new mode.
decider := &policy.InternalDecider{Mode: zddc.ModeStrict}
doStrict := func(method, target, email string, body []byte) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
ctx = context.WithValue(ctx, DeciderKey, decider)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeFileAPI(cfg, rec, req)
return rec
}
// Vendor's leaf rwcd grant in archive/Acme/.zddc is overridden by
// the root deny under strict mode.
rec := doStrict(http.MethodPut, "/Project-X/archive/Acme/incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
if rec.Code != http.StatusForbidden {
t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String())
}
}
// --- staging↔working mirror -------------------------------------------------
// stagingMirrorURL builds a URL-safe target path for a transmittal folder
// name with spaces and parens, mirroring how a real client would encode it.
func stagingMirrorURL(project, folder string) string {
return "/" + project + "/staging/" + url.PathEscape(folder) + "/"
}
func TestFileAPI_StagingMirror_TRN(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - Foundation Plans"
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("staging mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
}
// Staging side exists with auto-own.
stagingDir := filepath.Join(root, "Proj/staging", folder)
if info, err := os.Stat(stagingDir); err != nil || !info.IsDir() {
t.Fatalf("staging folder not created: err=%v", err)
}
if _, err := os.Stat(filepath.Join(stagingDir, ".zddc")); err != nil {
t.Errorf("staging auto-own .zddc missing: %v", err)
}
// Working mirror exists with auto-own.
workingDir := filepath.Join(root, "Proj/working", folder)
if info, err := os.Stat(workingDir); err != nil || !info.IsDir() {
t.Fatalf("working mirror not created: err=%v", err)
}
mirrorZ, err := os.ReadFile(filepath.Join(workingDir, ".zddc"))
if err != nil {
t.Fatalf("working mirror auto-own .zddc missing: %v", err)
}
if !strings.Contains(string(mirrorZ), "alice@example.com: rwcda") {
t.Errorf("mirror .zddc missing creator grant: %s", mirrorZ)
}
}
func TestFileAPI_StagingMirror_SUB(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
folder := "2026-07-01_vendor-EM-SUB-0017 (RSA) - Review Notes"
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d", rec.Code)
}
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); err != nil {
t.Errorf("SUB-tracked folder should mirror; got err=%v", err)
}
}
func TestFileAPI_StagingMirror_NonTransmittalNameSkipped(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
rec := do(http.MethodPost, "/Proj/staging/scratch/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d", rec.Code)
}
// staging/scratch/ exists.
if _, err := os.Stat(filepath.Join(root, "Proj/staging/scratch")); err != nil {
t.Fatalf("staging/scratch not created: %v", err)
}
// No working/ sibling — name doesn't parse as transmittal.
if _, err := os.Stat(filepath.Join(root, "Proj/working/scratch")); !os.IsNotExist(err) {
t.Errorf("non-transmittal name must NOT mirror; got err=%v", err)
}
}
func TestFileAPI_StagingMirror_MdlTrackingSkipped(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
folder := "2026-06-15_proj-EM-MDL-0001 (IFR) - Master Deliverables List"
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d", rec.Code)
}
// MDL deliverables are tracked in archive/<party>/mdl/, not via the
// working↔staging pairing — no mirror.
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
t.Errorf("-MDL- tracking must NOT mirror; got err=%v", err)
}
}
func TestFileAPI_StagingMirror_DeepPathSkipped(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
// mkdir of staging/<name>/sub/ (depth 4) — only depth-3 (immediate
// child of staging/) qualifies for mirroring.
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - x"
if err := os.MkdirAll(filepath.Join(root, "Proj/staging", folder), 0o755); err != nil {
t.Fatal(err)
}
rec := do(http.MethodPost, "/Proj/staging/"+url.PathEscape(folder)+"/sub/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Fatalf("deep mkdir: got %d: %s", rec.Code, rec.Body.String())
}
// The transmittal folder did not get a mirror retroactively because
// the mirror only fires on depth-3 mkdirs.
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
t.Errorf("deep mkdir should not retroactively mirror; got err=%v", err)
}
}
func TestFileAPI_StagingMirror_Idempotent(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
// Pre-create the working sibling with a sentinel file so we can detect
// if the mirror code blew it away.
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - existing"
mirrorDir := filepath.Join(root, "Proj/working", folder)
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(mirrorDir, "preexisting.md")
if err := os.WriteFile(sentinel, []byte("user content"), 0o644); err != nil {
t.Fatal(err)
}
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d", rec.Code)
}
// Sentinel still exists — mirror was idempotent (no-op when sibling
// already present).
if _, err := os.Stat(sentinel); err != nil {
t.Errorf("idempotency: pre-existing content gone: %v", err)
}
}