feat(zddc)!: per-party WORM + auto-own; case-fold tool availability
BREAKING CHANGE. Project-level Issued/Received/Incoming folders no
longer carry special semantics. WORM enforcement and auto-ownership
move to the per-party canonical layout:
- WORM mask now triggers on archive/<party>/received/ and
archive/<party>/issued/ (any case, any party)
- Auto-own .zddc writes on first mkdir under working/, staging/,
or archive/<party>/incoming/ (any case)
Predicate API:
- IsAutoOwnPath(parentDir, fsRoot) — replaces IsAutoOwnParent(name)
- IsWormPath(requestPath) — same name, new pattern
- WormFolderLevelIndex unchanged signature, new pattern
Legacy SpecialFolderNames / AutoOwnFolderNames / WormFolderNames /
IsAutoOwnParent are deleted (no Deprecated: stubs — early-development
project, no back-compat to preserve).
Tool availability (apps/availability.go) is case-fold throughout:
- mdedit: descendants of working/
- transmittal: descendants of staging/
- classifier: descendants of working/, staging/, or
archive/<party>/incoming/
Working/, WORKING/, working/ all match identically.
Test fixtures rewritten:
- special_test.go: covers IsAutoOwnPath / IsWormPath /
WormFolderLevelIndex / ResolveCanonical / canonical lists
- availability_test.go: per-party rules, case-fold scenarios
- fileapi_test.go: rolePermissionsTestSetup now seeds
Project-X/archive/Acme/{incoming,issued,received}/ rather than
Vendor/{Incoming,Issued,Received}/ at the project root
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a471de8788
commit
9f97bfab3e
6 changed files with 319 additions and 228 deletions
|
|
@ -3,66 +3,55 @@ package apps
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Folder name conventions that gate which tools are virtually available
|
|
||||||
// at a given path. The names are case-sensitive; ZDDC convention uses
|
|
||||||
// the capitalized forms. The full canonical list lives in
|
|
||||||
// zddc/internal/zddc/special.go (SpecialFolderNames) — this file pulls
|
|
||||||
// the relevant subsets from there to avoid duplication.
|
|
||||||
var (
|
|
||||||
// Subset of zddc.AutoOwnFolderNames where classifier is virtually
|
|
||||||
// available (the same three folders that grant mkdir auto-ownership).
|
|
||||||
folderNamesIncomingWorkingStaging = zddc.AutoOwnFolderNames
|
|
||||||
folderNamesWorking = []string{"Working"}
|
|
||||||
folderNamesStaging = []string{"Staging"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppAvailableAt reports whether app's virtual HTML can be served at
|
// AppAvailableAt reports whether app's virtual HTML can be served at
|
||||||
// requestDir. Rules:
|
// requestDir. Rules (case-insensitive on canonical folder names):
|
||||||
//
|
//
|
||||||
// - archive: every directory (multi-project, project, archive, vendor)
|
// - archive: every directory (multi-project, project, archive, party)
|
||||||
// - browse: every directory (generic file listing — also the default
|
// - browse: every directory (generic file listing — also the default
|
||||||
// served at folder URLs without an index.html; see directory.go)
|
// served at folder URLs without an index.html; see directory.go)
|
||||||
// - classifier: requestDir is, or descends from, a folder named
|
// - classifier: requestDir is, or descends from, a folder named
|
||||||
// "Incoming", "Working", or "Staging" (the directories where
|
// "working", "staging", or "incoming" (the directories where
|
||||||
// incoming/outgoing files get classified)
|
// in-flight files get classified)
|
||||||
// - mdedit: requestDir is, or descends from, a "Working" folder
|
// - mdedit: requestDir is, or descends from, a "working" folder
|
||||||
// (where markdown drafts are written and edited)
|
// (where markdown drafts are written and edited, including review
|
||||||
// - transmittal: requestDir is, or descends from, a "Staging" folder
|
// responses drafted in working/<rs-name>/)
|
||||||
|
// - transmittal: requestDir is, or descends from, a "staging" folder
|
||||||
// (where outgoing transmittals are prepared)
|
// (where outgoing transmittals are prepared)
|
||||||
// - landing: only at the deployment root (the project picker)
|
// - landing: only at the deployment root (the project picker)
|
||||||
//
|
//
|
||||||
// Operators can always drop a real <name>.html file at any path to override
|
// Operators can always drop a real <name>.html file at any path to
|
||||||
// — that path is served by the static handler regardless of this function's
|
// override — that path is served by the static handler regardless of
|
||||||
// result. AppAvailableAt is consulted only when no real file exists.
|
// this function's result. AppAvailableAt is consulted only when no
|
||||||
|
// real file exists.
|
||||||
|
//
|
||||||
|
// In the canonical layout, "incoming" only appears at
|
||||||
|
// archive/<party>/incoming/, so checking "any ancestor named incoming"
|
||||||
|
// is equivalent to checking "under a per-party incoming folder."
|
||||||
func AppAvailableAt(root, requestDir, app string) bool {
|
func AppAvailableAt(root, requestDir, app string) bool {
|
||||||
root = filepath.Clean(root)
|
root = filepath.Clean(root)
|
||||||
requestDir = filepath.Clean(requestDir)
|
requestDir = filepath.Clean(requestDir)
|
||||||
|
|
||||||
switch app {
|
switch app {
|
||||||
case "archive":
|
case "archive", "browse":
|
||||||
return true
|
|
||||||
case "browse":
|
|
||||||
return true
|
return true
|
||||||
case "landing":
|
case "landing":
|
||||||
return requestDir == root
|
return requestDir == root
|
||||||
case "classifier":
|
case "classifier":
|
||||||
return inAncestorWithName(root, requestDir, folderNamesIncomingWorkingStaging)
|
return inAncestorWithName(root, requestDir, "working", "staging", "incoming")
|
||||||
case "mdedit":
|
case "mdedit":
|
||||||
return inAncestorWithName(root, requestDir, folderNamesWorking)
|
return inAncestorWithName(root, requestDir, "working")
|
||||||
case "transmittal":
|
case "transmittal":
|
||||||
return inAncestorWithName(root, requestDir, folderNamesStaging)
|
return inAncestorWithName(root, requestDir, "staging")
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// inAncestorWithName reports whether requestDir is, or has an ancestor
|
// inAncestorWithName reports whether requestDir is, or has an ancestor
|
||||||
// (not including root itself), named one of names. The match is on the
|
// (not including root itself), whose last segment case-folds to one
|
||||||
// last segment of each directory in the chain root → requestDir.
|
// of names. Match is on segment names, case-insensitively.
|
||||||
func inAncestorWithName(root, requestDir string, names []string) bool {
|
func inAncestorWithName(root, requestDir string, names ...string) bool {
|
||||||
if requestDir == root {
|
if requestDir == root {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +61,7 @@ func inAncestorWithName(root, requestDir string, names []string) bool {
|
||||||
}
|
}
|
||||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
if part == n {
|
if strings.EqualFold(part, n) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,38 +14,46 @@ func TestAppAvailableAt(t *testing.T) {
|
||||||
// archive: everywhere
|
// archive: everywhere
|
||||||
{root, "archive", true},
|
{root, "archive", true},
|
||||||
{root + "/Project-A", "archive", true},
|
{root + "/Project-A", "archive", true},
|
||||||
{root + "/Project-A/Working", "archive", true},
|
{root + "/Project-A/working", "archive", true},
|
||||||
{root + "/Project-A/Outgoing", "archive", true},
|
{root + "/Project-A/some-other-folder", "archive", true},
|
||||||
|
|
||||||
// landing: only at root
|
// landing: only at root
|
||||||
{root, "landing", true},
|
{root, "landing", true},
|
||||||
{root + "/Project-A", "landing", false},
|
{root + "/Project-A", "landing", false},
|
||||||
|
|
||||||
// classifier: Incoming/Working/Staging and subtrees
|
// classifier: working/, staging/, archive/<party>/incoming/ and subtrees
|
||||||
{root, "classifier", false},
|
{root, "classifier", false},
|
||||||
{root + "/Project-A", "classifier", false},
|
{root + "/Project-A", "classifier", false},
|
||||||
{root + "/Project-A/Incoming", "classifier", true},
|
{root + "/Project-A/working", "classifier", true},
|
||||||
{root + "/Project-A/Incoming/SubDir", "classifier", true},
|
{root + "/Project-A/working/deep/nested/path", "classifier", true},
|
||||||
{root + "/Project-A/Working", "classifier", true},
|
{root + "/Project-A/staging", "classifier", true},
|
||||||
{root + "/Project-A/Staging", "classifier", true},
|
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true},
|
||||||
{root + "/Project-A/Outgoing", "classifier", false},
|
{root + "/Project-A/archive/ACME/incoming", "classifier", true},
|
||||||
{root + "/Project-A/Working/deep/nested/path", "classifier", true},
|
{root + "/Project-A/archive/ACME/incoming/sub", "classifier", true},
|
||||||
|
{root + "/Project-A/archive/ACME/received", "classifier", false},
|
||||||
|
{root + "/Project-A/archive/ACME/issued", "classifier", false},
|
||||||
|
{root + "/Project-A/archive/ACME/mdl", "classifier", false},
|
||||||
|
{root + "/Project-A/some-other-folder", "classifier", false},
|
||||||
|
|
||||||
// mdedit: Working only
|
// mdedit: working/ only (review responses live in working/<rs-name>/)
|
||||||
|
{root + "/Project-A/working", "mdedit", true},
|
||||||
|
{root + "/Project-A/working/sub", "mdedit", true},
|
||||||
|
{root + "/Project-A/staging", "mdedit", false},
|
||||||
|
{root + "/Project-A/archive/ACME/incoming", "mdedit", false},
|
||||||
|
|
||||||
|
// transmittal: staging/ only
|
||||||
|
{root + "/Project-A/staging", "transmittal", true},
|
||||||
|
{root + "/Project-A/staging/sub", "transmittal", true},
|
||||||
|
{root + "/Project-A/working", "transmittal", false},
|
||||||
|
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
|
||||||
|
|
||||||
|
// case-fold: any case of canonical names matches
|
||||||
{root + "/Project-A/Working", "mdedit", true},
|
{root + "/Project-A/Working", "mdedit", true},
|
||||||
{root + "/Project-A/Working/SubDir", "mdedit", true},
|
{root + "/Project-A/WORKING", "mdedit", true},
|
||||||
{root + "/Project-A/Incoming", "mdedit", false},
|
|
||||||
{root + "/Project-A/Staging", "mdedit", false},
|
|
||||||
|
|
||||||
// transmittal: Staging only
|
|
||||||
{root + "/Project-A/Staging", "transmittal", true},
|
{root + "/Project-A/Staging", "transmittal", true},
|
||||||
{root + "/Project-A/Staging/SubDir", "transmittal", true},
|
{root + "/Project-A/STAGING", "transmittal", true},
|
||||||
{root + "/Project-A/Incoming", "transmittal", false},
|
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
|
||||||
{root + "/Project-A/Working", "transmittal", false},
|
{root + "/Project-A/Archive/ACME/incoming", "classifier", true},
|
||||||
|
|
||||||
// case-sensitivity: lowercase doesn't match
|
|
||||||
{root + "/Project-A/working", "mdedit", false},
|
|
||||||
{root + "/Project-A/staging", "transmittal", false},
|
|
||||||
|
|
||||||
// unknown app
|
// unknown app
|
||||||
{root + "/Project-A", "weird", false},
|
{root + "/Project-A", "weird", false},
|
||||||
|
|
|
||||||
|
|
@ -516,16 +516,16 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-ownership: when the parent directory is one of the
|
// Auto-ownership: when the parent directory is one of the canonical
|
||||||
// auto-own special folders (Incoming/Working/Staging) and the
|
// auto-own positions (working/, staging/, archive/<party>/incoming/)
|
||||||
// caller has an authenticated email, write a .zddc into the new
|
// and the caller has an authenticated email, write a .zddc into the
|
||||||
// folder granting the creator full control. The grant is identical
|
// new folder granting the creator full control. The grant is
|
||||||
// to what the operator would write by hand — direct email pattern,
|
// identical to what the operator would write by hand — direct email
|
||||||
// "rwcda" verb set — so the creator can later edit the file
|
// pattern, "rwcda" verb set — so the creator can later edit the
|
||||||
// normally to add collaborators.
|
// file normally to add collaborators.
|
||||||
if email := EmailFromContext(r); email != "" {
|
if email := EmailFromContext(r); email != "" {
|
||||||
parentName := filepath.Base(filepath.Dir(abs))
|
parentDir := filepath.Dir(abs)
|
||||||
if zddc.IsAutoOwnParent(parentName) {
|
if zddc.IsAutoOwnPath(parentDir, cfg.Root) {
|
||||||
if err := zddc.WriteAutoOwnZddc(abs, email); err != nil {
|
if err := zddc.WriteAutoOwnZddc(abs, email); err != nil {
|
||||||
slog.Warn("auto-own .zddc write failed", "path", abs, "err", err)
|
slog.Warn("auto-own .zddc write failed", "path", abs, "err", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -378,19 +378,22 @@ func TestFileAPI_AnonymousDenied(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rolePermissionsTestSetup creates a vendor-exchange shape:
|
// rolePermissionsTestSetup creates a project + per-party exchange shape:
|
||||||
//
|
//
|
||||||
// root .zddc: _company:r, _doc_controller:rwcda
|
// root .zddc: _company:r, _doc_controller:rwcda
|
||||||
// Vendor/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:""
|
// <project>/archive/Acme/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:""
|
||||||
// roles defined at root.
|
// 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.
|
// 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) {
|
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()
|
t.Helper()
|
||||||
root = t.TempDir()
|
root = t.TempDir()
|
||||||
|
|
||||||
// Root .zddc — company gets r, doc_controller gets rwcda. Roles
|
// Root .zddc — company gets r, doc_controller gets rwcda. Roles
|
||||||
// defined here so the vendor subtree's permissions can reference
|
// defined here so the per-party subtree's permissions can reference
|
||||||
// them by name.
|
// them by name.
|
||||||
rootZ := []byte(`roles:
|
rootZ := []byte(`roles:
|
||||||
_company:
|
_company:
|
||||||
|
|
@ -408,25 +411,21 @@ acl:
|
||||||
t.Fatalf("root .zddc: %v", err)
|
t.Fatalf("root .zddc: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendor subtree: narrow scope.
|
// Project + per-party canonical layout.
|
||||||
vendorDir := filepath.Join(root, "Vendor")
|
partyDir := filepath.Join(root, "Project-X", "archive", "Acme")
|
||||||
if err := os.MkdirAll(filepath.Join(vendorDir, "Incoming"), 0o755); err != nil {
|
for _, sub := range []string{"incoming", "issued", "received"} {
|
||||||
t.Fatalf("mkdir Vendor/Incoming: %v", err)
|
if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir party/%s: %v", sub, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Join(vendorDir, "Issued"), 0o755); err != nil {
|
partyZ := []byte(`acl:
|
||||||
t.Fatalf("mkdir Vendor/Issued: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Join(vendorDir, "Received"), 0o755); err != nil {
|
|
||||||
t.Fatalf("mkdir Vendor/Received: %v", err)
|
|
||||||
}
|
|
||||||
vendorZ := []byte(`acl:
|
|
||||||
permissions:
|
permissions:
|
||||||
vendor_acme: rwcd
|
vendor_acme: rwcd
|
||||||
_doc_controller: rwcda
|
_doc_controller: rwcda
|
||||||
_company: ""
|
_company: ""
|
||||||
`)
|
`)
|
||||||
if err := os.WriteFile(filepath.Join(vendorDir, ".zddc"), vendorZ, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil {
|
||||||
t.Fatalf("vendor .zddc: %v", err)
|
t.Fatalf("party .zddc: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
@ -462,84 +461,84 @@ acl:
|
||||||
func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
|
func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
|
||||||
_, do, _ := rolePermissionsTestSetup(t)
|
_, do, _ := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Vendor PUTs into their Incoming → 201.
|
// Vendor PUTs into their incoming → 201.
|
||||||
rec := do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
rec := do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("PUT vendor → Incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Vendor overwrites the same file → 200 (rwcd has w).
|
// Vendor overwrites the same file → 200 (rwcd has w).
|
||||||
rec = do(http.MethodPut, "/Vendor/Incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("PUT vendor → Incoming overwrite: want 200, got %d", rec.Code)
|
t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) {
|
func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) {
|
||||||
_, do, root := rolePermissionsTestSetup(t)
|
_, do, root := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Seed an existing Issued file.
|
// Seed an existing issued file.
|
||||||
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendor cannot overwrite — ancestor grant masked to r in Issued.
|
// Vendor cannot overwrite — ancestor grant masked to r in issued.
|
||||||
rec := do(http.MethodPut, "/Vendor/Issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil)
|
rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("PUT vendor → Issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("PUT vendor → issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// Vendor cannot delete.
|
// Vendor cannot delete.
|
||||||
rec = do(http.MethodDelete, "/Vendor/Issued/spec.pdf", "rep@acme.com", nil, nil)
|
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", nil, nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("DELETE vendor → Issued: want 403, got %d", rec.Code)
|
t.Fatalf("DELETE vendor → issued: want 403, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
// Vendor cannot create new files — they have no explicit .zddc grant
|
// Vendor cannot create new files — they have no explicit .zddc grant
|
||||||
// at the Issued folder, so the WORM split reduces their inherited
|
// at the issued folder, so the WORM split reduces their inherited
|
||||||
// rwcd to r-only.
|
// rwcd to r-only.
|
||||||
rec = do(http.MethodPut, "/Vendor/Issued/new.pdf", "rep@acme.com", []byte("x"), nil)
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/new.pdf", "rep@acme.com", []byte("x"), nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("PUT vendor → Issued (create): want 403 (no explicit grant at Issued), got %d", rec.Code)
|
t.Fatalf("PUT vendor → issued (create): want 403 (no explicit grant at issued), got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) {
|
func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) {
|
||||||
_, do, root := rolePermissionsTestSetup(t)
|
_, do, root := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Without a .zddc at Vendor/Issued/ explicitly granting cr, the dc's
|
// Without a .zddc at archive/Acme/issued/ explicitly granting cr,
|
||||||
// inherited rwcda is masked to r. They cannot create.
|
// the dc's inherited rwcda is masked to r. They cannot create.
|
||||||
rec := do(http.MethodPut, "/Vendor/Issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil)
|
rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("dc without explicit grant → Issued: want 403, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("dc without explicit grant → issued: want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operator places an explicit grant at Vendor/Issued/.zddc. Now dc
|
// Operator places an explicit grant at archive/Acme/issued/.zddc.
|
||||||
// has cr at-or-below the WORM folder, which survives the mask.
|
// Now dc has cr at-or-below the WORM folder, which survives the mask.
|
||||||
issuedZ := []byte(`acl:
|
issuedZ := []byte(`acl:
|
||||||
permissions:
|
permissions:
|
||||||
_doc_controller: cr
|
_doc_controller: cr
|
||||||
`)
|
`)
|
||||||
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
|
||||||
t.Fatalf("write Issued .zddc: %v", err)
|
t.Fatalf("write issued .zddc: %v", err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("dc with explicit grant → Issued: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("dc with explicit grant → issued: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
got, _ := os.ReadFile(filepath.Join(root, "Vendor/Issued/2026-Q2-spec.pdf"))
|
got, _ := os.ReadFile(filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2-spec.pdf"))
|
||||||
if string(got) != "CONTROLLED" {
|
if string(got) != "CONTROLLED" {
|
||||||
t.Fatalf("body: %q", got)
|
t.Fatalf("body: %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dc still cannot overwrite — explicit grant is cr, no w.
|
// dc still cannot overwrite — explicit grant is cr, no w.
|
||||||
rec = do(http.MethodPut, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("dc PUT overwrite → Issued: want 403, got %d", rec.Code)
|
t.Fatalf("dc PUT overwrite → issued: want 403, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
// dc still cannot delete.
|
// dc still cannot delete.
|
||||||
rec = do(http.MethodDelete, "/Vendor/Issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
|
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("dc DELETE → Issued: want 403, got %d", rec.Code)
|
t.Fatalf("dc DELETE → issued: want 403, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,29 +553,29 @@ func TestFileAPI_WORM_AdminBypass(t *testing.T) {
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(cfg.Root)
|
zddc.InvalidateCache(cfg.Root)
|
||||||
|
|
||||||
// Seed an Issued file and have root@ delete it (escape hatch).
|
// Seed an issued file and have root@ delete it (escape hatch).
|
||||||
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/mistake.pdf"), []byte("oops"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/mistake.pdf"), []byte("oops"), 0o644); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
rec := do(http.MethodDelete, "/Vendor/Issued/mistake.pdf", "root@example.com", nil, nil)
|
rec := do(http.MethodDelete, "/Project-X/archive/Acme/issued/mistake.pdf", "root@example.com", nil, nil)
|
||||||
if rec.Code != http.StatusNoContent {
|
if rec.Code != http.StatusNoContent {
|
||||||
t.Fatalf("admin DELETE → Issued: want 204, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("admin DELETE → issued: want 204, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
||||||
_, do, root := rolePermissionsTestSetup(t)
|
_, do, root := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Vendor creates a folder under their Incoming. Server should
|
// Vendor creates a folder under their incoming. Server should
|
||||||
// auto-write a .zddc granting them rwcda on the new subtree.
|
// auto-write a .zddc granting them rwcda on the new subtree.
|
||||||
rec := do(http.MethodPost, "/Vendor/Incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Project-X/archive/Acme/incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
autoZ := filepath.Join(root, "Vendor/Incoming/2026-05-15-issue/.zddc")
|
autoZ := filepath.Join(root, "Project-X/archive/Acme/incoming/2026-05-15-issue/.zddc")
|
||||||
data, err := os.ReadFile(autoZ)
|
data, err := os.ReadFile(autoZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("auto .zddc not written: %v", err)
|
t.Fatalf("auto .zddc not written: %v", err)
|
||||||
|
|
@ -595,7 +594,7 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
||||||
// now PUT a brand-new file inside their owned folder where they
|
// now PUT a brand-new file inside their owned folder where they
|
||||||
// otherwise wouldn't have ACL admin rights.
|
// otherwise wouldn't have ACL admin rights.
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
rec = do(http.MethodPut, "/Vendor/Incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
|
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 {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -604,25 +603,25 @@ func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
||||||
func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
||||||
_, do, root := rolePermissionsTestSetup(t)
|
_, do, root := rolePermissionsTestSetup(t)
|
||||||
|
|
||||||
// Place an explicit grant so dc has cr at the Issued level.
|
// Place an explicit grant so dc has cr at the issued level.
|
||||||
issuedZ := []byte("acl:\n permissions:\n _doc_controller: cr\n")
|
issuedZ := []byte("acl:\n permissions:\n _doc_controller: cr\n")
|
||||||
if err := os.WriteFile(filepath.Join(root, "Vendor/Issued/.zddc"), issuedZ, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
|
||||||
t.Fatalf("seed Issued .zddc: %v", err)
|
t.Fatalf("seed issued .zddc: %v", err)
|
||||||
}
|
}
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
|
|
||||||
// Doc controller mkdir under Issued — should succeed (cr survives mask)
|
// Doc controller mkdir under issued — should succeed (cr survives mask)
|
||||||
// but should NOT auto-write an ownership .zddc (Issued is excluded
|
// but should NOT auto-write an ownership .zddc (issued is excluded
|
||||||
// from auto-own).
|
// from auto-own).
|
||||||
rec := do(http.MethodPost, "/Vendor/Issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
|
rec := do(http.MethodPost, "/Project-X/archive/Acme/issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
|
||||||
"X-ZDDC-Op": "mkdir",
|
"X-ZDDC-Op": "mkdir",
|
||||||
})
|
})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
autoZ := filepath.Join(root, "Vendor/Issued/2026-Q2/.zddc")
|
autoZ := filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2/.zddc")
|
||||||
if _, err := os.Stat(autoZ); !os.IsNotExist(err) {
|
if _, err := os.Stat(autoZ); !os.IsNotExist(err) {
|
||||||
t.Errorf("auto .zddc should NOT be written under Issued; got err=%v", err)
|
t.Errorf("auto .zddc should NOT be written under issued; got err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -657,9 +656,9 @@ func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) {
|
||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendor's leaf rwcd grant in Vendor/.zddc is overridden by the
|
// Vendor's leaf rwcd grant in archive/Acme/.zddc is overridden by
|
||||||
// root deny under strict mode.
|
// the root deny under strict mode.
|
||||||
rec := doStrict(http.MethodPut, "/Vendor/Incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
|
rec := doStrict(http.MethodPut, "/Project-X/archive/Acme/incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
|
||||||
if rec.Code != http.StatusForbidden {
|
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())
|
t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,28 +49,6 @@ var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"}
|
||||||
// MkdirAll for it.
|
// MkdirAll for it.
|
||||||
var VirtualOnlyCanonicalNames = []string{"reviewing"}
|
var VirtualOnlyCanonicalNames = []string{"reviewing"}
|
||||||
|
|
||||||
// SpecialFolderNames is preserved for callers that still reference the
|
|
||||||
// pre-canonical-folders model. Equivalent to the union of project-root
|
|
||||||
// AutoOwnCanonicalNames-at-root + WORM-canonical-names-at-party with
|
|
||||||
// the legacy PascalCase spellings, kept temporarily so dependent
|
|
||||||
// packages compile during the migration. New code should reference
|
|
||||||
// ProjectRootFolders / PartyFolders directly.
|
|
||||||
//
|
|
||||||
// Deprecated: use ProjectRootFolders or PartyFolders.
|
|
||||||
var SpecialFolderNames = []string{"Incoming", "Working", "Staging", "Issued", "Received"}
|
|
||||||
|
|
||||||
// AutoOwnFolderNames is the legacy capitalised list. Predicates have
|
|
||||||
// moved to IsAutoOwnPath which understands the new layout.
|
|
||||||
//
|
|
||||||
// Deprecated: use AutoOwnCanonicalNames.
|
|
||||||
var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"}
|
|
||||||
|
|
||||||
// WormFolderNames is the legacy capitalised list. Predicates have
|
|
||||||
// moved to IsWormPath which understands the per-party layout.
|
|
||||||
//
|
|
||||||
// Deprecated: use PartyFolders + IsWormPath.
|
|
||||||
var WormFolderNames = []string{"Issued", "Received"}
|
|
||||||
|
|
||||||
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
||||||
// principalEmail rwcda and recording it in CreatedBy. Used by the file
|
// principalEmail rwcda and recording it in CreatedBy. Used by the file
|
||||||
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
|
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
|
||||||
|
|
@ -124,34 +102,59 @@ func ResolveCanonical(parentDir, logical string) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAutoOwnParent reports whether a folder named name should trigger
|
// IsAutoOwnPath reports whether parentDir is one of the canonical
|
||||||
// the mkdir auto-ownership .zddc write when a child is created inside
|
// auto-own positions in the ZDDC tree rooted at fsRoot. A child mkdir
|
||||||
// it. Used by the file API's mkdir handler.
|
// inside such a directory should receive a creator-owned .zddc.
|
||||||
func IsAutoOwnParent(name string) bool {
|
//
|
||||||
for _, n := range AutoOwnFolderNames {
|
// Canonical positions, relative to fsRoot:
|
||||||
if name == n {
|
//
|
||||||
return true
|
// - <project>/working
|
||||||
}
|
// - <project>/staging
|
||||||
|
// - <project>/archive/<party>/incoming
|
||||||
|
//
|
||||||
|
// Segment matches are case-insensitive on canonical names. The project
|
||||||
|
// and party names are unrestricted.
|
||||||
|
//
|
||||||
|
// parentDir and fsRoot are filesystem paths. parentDir must be inside
|
||||||
|
// fsRoot; otherwise the function returns false.
|
||||||
|
func IsAutoOwnPath(parentDir, fsRoot string) bool {
|
||||||
|
rel, err := filepath.Rel(fsRoot, parentDir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
switch len(parts) {
|
||||||
|
case 2:
|
||||||
|
// <project>/working or <project>/staging
|
||||||
|
return strings.EqualFold(parts[1], "working") || strings.EqualFold(parts[1], "staging")
|
||||||
|
case 4:
|
||||||
|
// <project>/archive/<party>/incoming
|
||||||
|
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "incoming")
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsWormPath reports whether requestPath is inside an "Issued" or
|
// IsWormPath reports whether requestPath crosses an
|
||||||
// "Received" subtree. The check is purely on path segments — a file
|
// archive/<party>/received/ or archive/<party>/issued/ segment chain.
|
||||||
// named "Issued.txt" does not trigger WORM, but
|
// Pure path-segment check; case-fold on canonical names.
|
||||||
// "/Project/Vendor/Issued/foo.pdf" does, as does
|
//
|
||||||
// "/Project/Vendor/Issued/" itself. requestPath may be a URL path
|
// The party segment is unrestricted — any directory under archive/ is
|
||||||
// ("/foo/bar") or a filesystem path; only segment names matter.
|
// treated as a party, including the self-folder. requestPath may be a
|
||||||
|
// URL path ("/Project/archive/ACME/issued/foo.pdf") or a filesystem
|
||||||
|
// path; only segment names matter.
|
||||||
func IsWormPath(requestPath string) bool {
|
func IsWormPath(requestPath string) bool {
|
||||||
clean := strings.Trim(filepath.ToSlash(requestPath), "/")
|
parts := splitPathSegments(requestPath)
|
||||||
if clean == "" {
|
for i := 0; i+2 < len(parts); i++ {
|
||||||
return false
|
if !strings.EqualFold(parts[i], "archive") {
|
||||||
}
|
continue
|
||||||
for _, seg := range strings.Split(clean, "/") {
|
}
|
||||||
for _, name := range WormFolderNames {
|
// parts[i+1] is the party name (anything).
|
||||||
if seg == name {
|
if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -165,41 +168,52 @@ func IsWormPath(requestPath string) bool {
|
||||||
// are the deliberate escape hatch for mis-filed documents.
|
// are the deliberate escape hatch for mis-filed documents.
|
||||||
//
|
//
|
||||||
// The WORM mask is split-aware via WormFolderLevelIndex: grants
|
// The WORM mask is split-aware via WormFolderLevelIndex: grants
|
||||||
// inherited from ancestors above the Issued/Received folder are
|
// inherited from ancestors above the received/issued folder are
|
||||||
// masked to read only ({r}), while grants at-or-below the WORM
|
// masked to read only ({r}), while grants at-or-below the WORM
|
||||||
// folder retain {r, c} so an operator can place a .zddc at the
|
// folder retain {r, c} so an operator can place a .zddc at the
|
||||||
// Issued folder explicitly granting `_doc_controller: cr`.
|
// received/issued folder explicitly granting `_doc_controller: cr`.
|
||||||
func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC }
|
func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC }
|
||||||
|
|
||||||
// WormFolderLevelIndex returns the chain index of the deepest
|
// WormFolderLevelIndex returns the chain index of the deepest
|
||||||
// "Issued" or "Received" segment in requestPath. The chain
|
// archive/<party>/(received|issued) segment in requestPath. The chain
|
||||||
// corresponds to the directory tree from root (index 0) to the
|
// corresponds to the directory tree from root (index 0) to the
|
||||||
// requested directory; level i is the .zddc at path segment depth i.
|
// requested directory; level i is the .zddc at path segment depth i.
|
||||||
//
|
//
|
||||||
// numLevels is len(chain.Levels); used to clamp results to the
|
// numLevels is len(chain.Levels); used to clamp results to the chain's
|
||||||
// chain's actual range (e.g. a request to a file inside an Issued
|
// actual range. URL segment i lives at chain index i+1 (root is chain
|
||||||
// folder has a chain that only covers up to the Issued directory,
|
// index 0), so the WORM segment at parts[i+2] corresponds to chain
|
||||||
// not the file itself).
|
// index i+3.
|
||||||
//
|
//
|
||||||
// Returns -1 if no WORM segment is in the request path or the
|
// Returns -1 if no WORM segment is in the request path or the computed
|
||||||
// computed index is out of range. The returned index satisfies
|
// index is out of range. The returned index satisfies
|
||||||
// 0 <= index < numLevels.
|
// 0 <= index < numLevels.
|
||||||
func WormFolderLevelIndex(requestPath string, numLevels int) int {
|
func WormFolderLevelIndex(requestPath string, numLevels int) int {
|
||||||
clean := strings.Trim(filepath.ToSlash(requestPath), "/")
|
if numLevels <= 0 {
|
||||||
if clean == "" || numLevels <= 0 {
|
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
parts := splitPathSegments(requestPath)
|
||||||
deepest := -1
|
deepest := -1
|
||||||
for i, seg := range strings.Split(clean, "/") {
|
for i := 0; i+2 < len(parts); i++ {
|
||||||
for _, name := range WormFolderNames {
|
if !strings.EqualFold(parts[i], "archive") {
|
||||||
if seg == name {
|
continue
|
||||||
// URL segment i lives at chain index i+1 (root is index 0).
|
}
|
||||||
idx := i + 1
|
if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
|
||||||
if idx < numLevels && idx > deepest {
|
idx := i + 3
|
||||||
deepest = idx
|
if idx < numLevels && idx > deepest {
|
||||||
}
|
deepest = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return deepest
|
return deepest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitPathSegments returns the slash-separated segments of p with
|
||||||
|
// empty elements removed. Tolerates leading/trailing slashes and
|
||||||
|
// mixed separators on Windows (via filepath.ToSlash).
|
||||||
|
func splitPathSegments(p string) []string {
|
||||||
|
clean := strings.Trim(filepath.ToSlash(p), "/")
|
||||||
|
if clean == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(clean, "/")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,34 +6,73 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsAutoOwnParent(t *testing.T) {
|
func TestIsAutoOwnPath(t *testing.T) {
|
||||||
yes := []string{"Incoming", "Working", "Staging"}
|
root := "/srv/zddc"
|
||||||
no := []string{"Issued", "Received", "incoming", "Random", "", "Working/sub"}
|
cases := map[string]bool{
|
||||||
for _, n := range yes {
|
// Project-root canonical positions.
|
||||||
if !IsAutoOwnParent(n) {
|
"/srv/zddc/Project/working": true,
|
||||||
t.Errorf("IsAutoOwnParent(%q) = false, want true", n)
|
"/srv/zddc/Project/staging": true,
|
||||||
}
|
"/srv/zddc/Project/Working": true, // case-fold
|
||||||
|
"/srv/zddc/Project/STAGING": true, // case-fold
|
||||||
|
"/srv/zddc/Project/archive": false,
|
||||||
|
"/srv/zddc/Project/reviewing": false,
|
||||||
|
"/srv/zddc/Project/random": false,
|
||||||
|
|
||||||
|
// Per-party position.
|
||||||
|
"/srv/zddc/Project/archive/ACME/incoming": true,
|
||||||
|
"/srv/zddc/Project/archive/ACME/Incoming": true, // case-fold
|
||||||
|
"/srv/zddc/Project/Archive/ACME/incoming": true, // case-fold archive
|
||||||
|
"/srv/zddc/Project/archive/ACME/received": false,
|
||||||
|
"/srv/zddc/Project/archive/ACME/issued": false,
|
||||||
|
"/srv/zddc/Project/archive/ACME/mdl": false,
|
||||||
|
|
||||||
|
// Wrong depth — incoming inside something other than archive/<party>/.
|
||||||
|
"/srv/zddc/Project/working/incoming": false,
|
||||||
|
"/srv/zddc/Project/random/sub/incoming": false,
|
||||||
|
"/srv/zddc/Project/incoming": false, // depth 1 with incoming
|
||||||
|
"/srv/zddc/Project/archive/incoming": false, // depth 2
|
||||||
|
"/srv/zddc/Project/archive/ACME/incoming/sub": false, // child of incoming, not incoming itself
|
||||||
|
|
||||||
|
// Outside root.
|
||||||
|
"/elsewhere/working": false,
|
||||||
|
// Root itself or one above.
|
||||||
|
"/srv/zddc": false,
|
||||||
|
"/srv/zddc/Project": false,
|
||||||
}
|
}
|
||||||
for _, n := range no {
|
for in, want := range cases {
|
||||||
if IsAutoOwnParent(n) {
|
if got := IsAutoOwnPath(in, root); got != want {
|
||||||
t.Errorf("IsAutoOwnParent(%q) = true, want false", n)
|
t.Errorf("IsAutoOwnPath(%q, %q) = %v, want %v", in, root, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsWormPath(t *testing.T) {
|
func TestIsWormPath(t *testing.T) {
|
||||||
cases := map[string]bool{
|
cases := map[string]bool{
|
||||||
"": false,
|
"": false,
|
||||||
"/": false,
|
"/": false,
|
||||||
"/Project/Issued": true,
|
"/Project/archive/ACME/issued": true,
|
||||||
"/Project/Issued/": true,
|
"/Project/archive/ACME/issued/": true,
|
||||||
"/Project/Issued/file.pdf": true,
|
"/Project/archive/ACME/issued/foo.pdf": true,
|
||||||
"/Project/Issued/sub/file.pdf": true,
|
"/Project/archive/ACME/received/x": true,
|
||||||
"/Project/Vendor/Issued/x.pdf": true,
|
"/Project/archive/ACME/Issued/x": true, // case-fold
|
||||||
"/Project/Vendor/Received/y": true,
|
"/Project/Archive/ACME/issued/x": true, // case-fold
|
||||||
"/Project/Working/draft.md": false,
|
"/Project/archive/ACME/ISSUED/x": true, // case-fold
|
||||||
"/Project/Working/Issued.txt": false, // file named Issued.txt — not a path segment
|
|
||||||
"/Project/issued/lower.pdf": false, // lowercase ≠ Issued
|
// Per-party MDL/incoming aren't WORM.
|
||||||
|
"/Project/archive/ACME/incoming/x": false,
|
||||||
|
"/Project/archive/ACME/mdl/x": false,
|
||||||
|
|
||||||
|
// Bare "issued" or "received" not under archive/<party>/ — no WORM.
|
||||||
|
"/Project/issued/x": false,
|
||||||
|
"/Project/received/x": false,
|
||||||
|
"/Project/working/issued.md": false, // file basename, not a path segment match
|
||||||
|
"/Project/working/issued": false, // "working" is not "archive"
|
||||||
|
|
||||||
|
// Self-folder is symmetric (any party name works).
|
||||||
|
"/Project/archive/Self-Org/issued/x.pdf": true,
|
||||||
|
|
||||||
|
// Nested or deep.
|
||||||
|
"/multi/Project/archive/Vendor/received/sub/file.pdf": true,
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := IsWormPath(in); got != want {
|
if got := IsWormPath(in); got != want {
|
||||||
|
|
@ -42,6 +81,36 @@ func TestIsWormPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWormFolderLevelIndex(t *testing.T) {
|
||||||
|
// Path /Project/archive/ACME/issued/foo.pdf
|
||||||
|
// parts: [Project, archive, ACME, issued, foo.pdf]
|
||||||
|
// issued is segment index 3, chain index 4.
|
||||||
|
if got := WormFolderLevelIndex("/Project/archive/ACME/issued/foo.pdf", 6); got != 4 {
|
||||||
|
t.Errorf("issued at depth 4: got %d, want 4", got)
|
||||||
|
}
|
||||||
|
// Same path, but the chain only has 4 levels (numLevels=4 → idx must be < 4).
|
||||||
|
if got := WormFolderLevelIndex("/Project/archive/ACME/issued/foo.pdf", 4); got != -1 {
|
||||||
|
t.Errorf("clamp: got %d, want -1", got)
|
||||||
|
}
|
||||||
|
// No WORM segment.
|
||||||
|
if got := WormFolderLevelIndex("/Project/working/foo.md", 5); got != -1 {
|
||||||
|
t.Errorf("no worm: got %d, want -1", got)
|
||||||
|
}
|
||||||
|
// Empty.
|
||||||
|
if got := WormFolderLevelIndex("", 5); got != -1 {
|
||||||
|
t.Errorf("empty: got %d, want -1", got)
|
||||||
|
}
|
||||||
|
// Nested archive/<party>/issued — deepest wins.
|
||||||
|
// parts: [P, archive, A, received, archive, B, issued, x]
|
||||||
|
// indices: 0 1 2 3 4 5 6 7
|
||||||
|
// outer match: i=1 (archive), parts[3]=received → segment idx 3, chain idx 4
|
||||||
|
// inner match: i=4 (archive), parts[6]=issued → segment idx 6, chain idx 7
|
||||||
|
// deepest = 7.
|
||||||
|
if got := WormFolderLevelIndex("/P/archive/A/received/archive/B/issued/x", 12); got != 7 {
|
||||||
|
t.Errorf("nested: got %d, want 7", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWormMaskStripsWDA(t *testing.T) {
|
func TestWormMaskStripsWDA(t *testing.T) {
|
||||||
rwcda, _ := ParseVerbSet("rwcda")
|
rwcda, _ := ParseVerbSet("rwcda")
|
||||||
masked := WormMask(rwcda)
|
masked := WormMask(rwcda)
|
||||||
|
|
@ -107,17 +176,29 @@ func TestResolveCanonicalMissingParent(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) {
|
func TestCanonicalLists(t *testing.T) {
|
||||||
want := map[string]bool{
|
hasAll := func(have, want []string) bool {
|
||||||
"Incoming": false, "Working": false, "Staging": false,
|
set := map[string]bool{}
|
||||||
"Issued": false, "Received": false,
|
for _, n := range have {
|
||||||
}
|
set[n] = true
|
||||||
for _, n := range SpecialFolderNames {
|
|
||||||
want[n] = true
|
|
||||||
}
|
|
||||||
for n, present := range want {
|
|
||||||
if !present {
|
|
||||||
t.Errorf("SpecialFolderNames missing %q", n)
|
|
||||||
}
|
}
|
||||||
|
for _, n := range want {
|
||||||
|
if !set[n] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !hasAll(ProjectRootFolders, []string{"archive", "working", "staging", "reviewing"}) {
|
||||||
|
t.Errorf("ProjectRootFolders = %v, missing entries", ProjectRootFolders)
|
||||||
|
}
|
||||||
|
if !hasAll(PartyFolders, []string{"mdl", "incoming", "received", "issued"}) {
|
||||||
|
t.Errorf("PartyFolders = %v, missing entries", PartyFolders)
|
||||||
|
}
|
||||||
|
if !hasAll(AutoOwnCanonicalNames, []string{"working", "staging", "incoming"}) {
|
||||||
|
t.Errorf("AutoOwnCanonicalNames = %v, missing entries", AutoOwnCanonicalNames)
|
||||||
|
}
|
||||||
|
if !hasAll(VirtualOnlyCanonicalNames, []string{"reviewing"}) {
|
||||||
|
t.Errorf("VirtualOnlyCanonicalNames = %v, missing entries", VirtualOnlyCanonicalNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue