Answers "can roles reset as well as add?" — yes, both now.
Role membership UNIONS across the cascade:
- A deeper .zddc that defines an inherited role again with one
extra member ADDS that member (was: deepest definition shadowed
the ancestor's entirely).
- New `reset: true` on a role definition breaks the union — that
level's members are authoritative, ancestor definitions above
are excluded; descendants below still union on top. Use it to
give a project its own team independent of a deployment-wide
default.
- lookupRoleMembers / RoleMembers reworked: walk deep→shallow,
union members, stop at the first reset:true; finally fold in
chain.Embedded.Roles as the baseline so a role declared only in
defaults.zddc.yaml is "defined" (and a deployment's on-disk
redefinition unions on top).
Admin checks are now role-aware:
- IsSubtreeAdmin / CanEditZddc's strict-ancestor scan use
MatchesPrincipal instead of MatchesPattern, so `admins:
[document_controller]` resolves to the role's members. The
strict-ancestor scan resolves roles only up to level i, so a
role defined at the deepest level (= dirPath) never confers
self-edit rights.
Two standard roles ship in defaults.zddc.yaml (empty members — a
fresh deployment grants nothing until they're populated):
document_controller — files into the WORM zones. Gets:
- rw at the project level (read + overwrite-existing; NOT c, so
it can't make arbitrary folders)
- rwc at archive/ (can create party subfolders)
- subtree-admin at working/ and staging/ (full create + manage,
including taking over a fenced per-user home) — scoped HERE,
not at the project root, so the WORM constraint still binds
it in archive/<party>/received|issued
- listed in worm: on received/ and issued/ → write-once-create
survives the WORM mask
project_team — read-only across the project. The per-user
working home's fenced auto-own .zddc (rwcda for the creator)
wins via deepest-match, so "read-only except what I own" falls
out of the cascade with no special rule. Inside received/issued
their r is preserved (worm: doesn't strip read).
archive/<party>/ gains `auto_own: true` (UNFENCED) so whoever
creates a party subtree (normally the doc controller) owns it and
can set up that counterparty's .zddc afterward — without fencing,
project_team:r still cascades through to received/issued.
Tests: roles_test (union + reset), standardroles_test (the
doc-controller scoped-create matrix + project-team read-only-except-
owned), ensure_test updated for the new party-folder auto-own.
fileapi_test's WORM doc-controller test already uses worm: [role].
All Go + 248 Playwright tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
7.4 KiB
Go
206 lines
7.4 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "Proj", "working", "alice@x.com", "notes.md")
|
|
|
|
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
|
if err != nil {
|
|
t.Fatalf("ensure: %v", err)
|
|
}
|
|
if resolved != target {
|
|
t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target)
|
|
}
|
|
|
|
// working/ is now created with auto-own .zddc.
|
|
autoZ := filepath.Join(root, "Proj", "working", ".zddc")
|
|
data, err := os.ReadFile(autoZ)
|
|
if err != nil {
|
|
t.Fatalf("auto-own .zddc not written at working/: %v", err)
|
|
}
|
|
body := string(data)
|
|
if !strings.Contains(body, "alice@x.com: rwcda") {
|
|
t.Errorf("auto-own grant missing: %s", body)
|
|
}
|
|
if !strings.Contains(body, "created_by: alice@x.com") {
|
|
t.Errorf("created_by missing: %s", body)
|
|
}
|
|
|
|
// alice@x.com/ subfolder gets a FENCED auto-own .zddc — private by
|
|
// default so other users can't read alice's drafts via ancestor
|
|
// cascade. alice can edit the file later to add collaborators.
|
|
homeZddc := filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc")
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil {
|
|
t.Errorf("subfolder not created: %v", err)
|
|
}
|
|
homeData, err := os.ReadFile(homeZddc)
|
|
if err != nil {
|
|
t.Fatalf("per-user-home auto-own .zddc not written: %v", err)
|
|
}
|
|
homeBody := string(homeData)
|
|
if !strings.Contains(homeBody, "alice@x.com: rwcda") {
|
|
t.Errorf("per-user-home grant missing: %s", homeBody)
|
|
}
|
|
if !strings.Contains(homeBody, "inherit: false") {
|
|
t.Errorf("per-user-home .zddc should have inherit: false; got: %s", homeBody)
|
|
}
|
|
}
|
|
|
|
// staging/<folder>/ is NOT fenced — staging is a shared lane (transmittal
|
|
// folders are date+tracking-named, not per-user). Only per-user homes
|
|
// under working/ get the fence.
|
|
func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "Proj", "staging",
|
|
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", "doc.pdf")
|
|
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
|
|
t.Fatalf("ensure: %v", err)
|
|
}
|
|
// staging/<folder>/.zddc should not exist (only the parent staging/
|
|
// gets an auto-own; the date-named child is plain).
|
|
childZddc := filepath.Join(root, "Proj", "staging",
|
|
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", ".zddc")
|
|
if _, err := os.Stat(childZddc); !os.IsNotExist(err) {
|
|
t.Errorf("staging child should NOT have auto-own .zddc; got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Pre-create Working/ (PascalCase).
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
target := filepath.Join(root, "Proj", "working", "foo.md")
|
|
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
|
if err != nil {
|
|
t.Fatalf("ensure: %v", err)
|
|
}
|
|
|
|
// Resolved path uses on-disk Working/ casing.
|
|
want := filepath.Join(root, "Proj", "Working", "foo.md")
|
|
if resolved != want {
|
|
t.Errorf("resolved=%q, want %q", resolved, want)
|
|
}
|
|
|
|
// No new working/ sibling.
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); !os.IsNotExist(err) {
|
|
t.Errorf("lowercase sibling should not exist; got err=%v", err)
|
|
}
|
|
|
|
// Working/ already existed before our call — no auto-own .zddc was
|
|
// retroactively written.
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "Working", ".zddc")); !os.IsNotExist(err) {
|
|
t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureCanonicalAncestors_PerPartyIncoming(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "Proj", "archive", "ACME", "incoming", "submission.pdf")
|
|
|
|
_, err := EnsureCanonicalAncestors(root, target, "rep@acme.com", 0o755)
|
|
if err != nil {
|
|
t.Fatalf("ensure: %v", err)
|
|
}
|
|
|
|
// archive/ created (no auto-own — archive/ itself is a plain
|
|
// container; the cascade declares no auto_own there).
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); err != nil {
|
|
t.Errorf("archive/ not created: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", ".zddc")); !os.IsNotExist(err) {
|
|
t.Errorf("archive/ should not have auto-own .zddc; got err=%v", err)
|
|
}
|
|
|
|
// archive/ACME/ created WITH auto-own (the cascade declares
|
|
// auto_own on the party-folder level so whoever creates a party
|
|
// subtree owns it — used by the document controller to set up a
|
|
// new counterparty's .zddc). Unfenced, so ancestor grants still
|
|
// reach inside (project_team:r through to received/issued).
|
|
partyZ := filepath.Join(root, "Proj", "archive", "ACME", ".zddc")
|
|
pdata, err := os.ReadFile(partyZ)
|
|
if err != nil {
|
|
t.Fatalf("auto-own .zddc at ACME/ not written: %v", err)
|
|
}
|
|
if !strings.Contains(string(pdata), "rep@acme.com: rwcda") {
|
|
t.Errorf("ACME/ auto-own missing rep grant: %s", pdata)
|
|
}
|
|
if strings.Contains(string(pdata), "inherit: false") {
|
|
t.Errorf("ACME/ auto-own should be UNFENCED; got: %s", pdata)
|
|
}
|
|
|
|
// archive/ACME/incoming/ created WITH auto-own.
|
|
autoZ := filepath.Join(root, "Proj", "archive", "ACME", "incoming", ".zddc")
|
|
data, err := os.ReadFile(autoZ)
|
|
if err != nil {
|
|
t.Fatalf("auto-own .zddc at incoming/ not written: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "rep@acme.com: rwcda") {
|
|
t.Errorf("incoming/ auto-own missing rep grant: %s", data)
|
|
}
|
|
}
|
|
|
|
func TestEnsureCanonicalAncestors_WormFoldersNoAutoOwn(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "Proj", "archive", "ACME", "issued", "spec.pdf")
|
|
|
|
_, err := EnsureCanonicalAncestors(root, target, "dc@mycompany.com", 0o755)
|
|
if err != nil {
|
|
t.Fatalf("ensure: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "issued")); err != nil {
|
|
t.Errorf("issued/ not created: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "issued", ".zddc")); !os.IsNotExist(err) {
|
|
t.Errorf("issued/ should NOT have auto-own .zddc (WORM); got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "Proj", "working", "anon.md")
|
|
|
|
_, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755)
|
|
if err != nil {
|
|
t.Fatalf("ensure: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
|
|
t.Errorf("working/ not created: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
|
|
t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureCanonicalAncestors_RejectsReviewing(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "Proj", "reviewing", "x.md")
|
|
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
|
if err == nil {
|
|
t.Errorf("expected error for write under reviewing/, got nil")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "reviewing")); !os.IsNotExist(err) {
|
|
t.Errorf("reviewing/ must NOT be created on disk; got err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) {
|
|
root := t.TempDir()
|
|
other := t.TempDir()
|
|
target := filepath.Join(other, "evil.md")
|
|
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
|
if err == nil {
|
|
t.Errorf("expected error for target outside fsRoot")
|
|
}
|
|
}
|