package handler import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "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 permissions:\n \"*@example.com\": rwcd\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 permissions:\n \"*@allowed.com\": rwcd\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()) } // 403 body carries JSON with the missing verb so the client toast // can render "you need here" and offer elevation when the // path-scoped /.profile/access reports an elevation grant. PUT to // a path with no existing file is gated on `c` (create); a PUT // over an existing file would gate on `w` instead — covered by // the test below. if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { t.Errorf("Content-Type = %q, want application/json", ct) } var body struct { Error string `json:"error"` MissingVerb string `json:"missing_verb"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode 403 body: %v (raw: %s)", err, rec.Body.String()) } if body.MissingVerb != "c" { t.Errorf("missing_verb = %q, want c (PUT to non-existing file gates on create)", body.MissingVerb) } } // TestFileAPI_PutDenyForbiddenOverwriteVerb — PUT over an existing file // gates on the write verb, so 403 reports missing_verb=w. Mirrors // TestFileAPI_PutDenyForbidden but with a seeded file. func TestFileAPI_PutDenyForbiddenOverwriteVerb(t *testing.T) { cfg, do, _ := fileAPITestSetup(t, nil, map[string]string{ "Working/seeded.md": "before", }) if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil { t.Fatalf("rewrite .zddc: %v", err) } zddc.InvalidateCache(cfg.Root) rec := do(http.MethodPut, "/Working/seeded.md", "alice@example.com", []byte("after"), nil) if rec.Code != http.StatusForbidden { t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String()) } var body struct { MissingVerb string `json:"missing_verb"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode 403 body: %v", err) } if body.MissingVerb != "w" { t.Errorf("missing_verb = %q, want w (PUT over existing file gates on write)", body.MissingVerb) } } func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) { _, do, _ := fileAPITestSetup(t, nil, nil) // .zddc as a leaf is carved out — gated on admin authority via the // decider, not blocked at the segment guard. Every other dot/ // underscore segment stays reserved. for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x", "/.zddc.d/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) { // Project-root mkdir is restricted to archive/ + system names // after the layout reshape; test mkdir at a depth where the // guard doesn't fire (under archive//incoming/). _, do, root := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming"}, nil) rec := do(http.MethodPost, "/Proj/archive/Acme/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, "Proj/archive/Acme/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{"Proj/archive/Acme/incoming/exists"}, nil) rec := do(http.MethodPost, "/Proj/archive/Acme/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) } } // TestFileAPI_MkdirProjectRootGuard — direct mkdir at // // is restricted: archive/ and system names (_/.-prefix) are allowed, // any other name (including the six virtual aggregator names) is // rejected with 409. func TestFileAPI_MkdirProjectRootGuard(t *testing.T) { _, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil) // Reject ad-hoc name. rec := do(http.MethodPost, "/Proj/notes/", "alice@example.com", nil, map[string]string{ "X-ZDDC-Op": "mkdir", }) if rec.Code != http.StatusConflict { t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String()) } // Reject each virtual aggregator name. for _, name := range []string{"ssr", "mdl", "rsk", "working", "staging", "reviewing"} { rec := do(http.MethodPost, "/Proj/"+name+"/", "alice@example.com", nil, map[string]string{ "X-ZDDC-Op": "mkdir", }) if rec.Code != http.StatusConflict { t.Fatalf("%s: want 409, got %d: %s", name, rec.Code, rec.Body.String()) } } // Allow archive/. rec = do(http.MethodPost, "/Proj/archive/", "alice@example.com", nil, map[string]string{ "X-ZDDC-Op": "mkdir", }) if rec.Code != http.StatusCreated { t.Fatalf("want 201 for /Proj/archive/, got %d: %s", rec.Code, rec.Body.String()) } // `_`/`.`-prefixed system names are caught earlier (resolveTargetPath // rejects them as reserved path segments with 404 — see fileapi.go // resolveTargetPath); the mkdir guard would also allow them, so the // composite end-state is reserved + 404. Tested elsewhere. } 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 // /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/. // // 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, } 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) } } // (The pre-reshape staging↔working mirror was retired: with staging at // archive//staging// and working at archive// // working//, the project-level pairing no longer maps cleanly. // Tests for the removed behaviour have been deleted.)