diff --git a/zddc/internal/zddc/ensure_test.go b/zddc/internal/zddc/ensure_test.go index 6891be0..206fd07 100644 --- a/zddc/internal/zddc/ensure_test.go +++ b/zddc/internal/zddc/ensure_test.go @@ -9,11 +9,10 @@ import ( func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) { root := t.TempDir() - // Per-user homes now live under archive//working// - // after the top-of-project reshape. The depth-3 working slot is - // the canonical-folder position; its auto-own .zddc is unfenced - // and the depth-4 per-user home gets the fenced one. - target := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", "notes.md") + // working/ is a top-level peer; its folder auto-owns the + // creator (unfenced — party admins still cascade in). Per-user email + // homes were abandoned in the reshape. + target := filepath.Join(root, "Proj", "working", "ACME", "notes.md") resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) if err != nil { @@ -23,13 +22,10 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) { t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target) } - // working/ is now created with auto-own .zddc (unfenced — party - // admins still cascade through, only the per-user home below is - // fenced). - autoZ := filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc") + autoZ := filepath.Join(root, "Proj", "working", "ACME", ".zddc") data, err := os.ReadFile(autoZ) if err != nil { - t.Fatalf("auto-own .zddc not written at working/: %v", err) + t.Fatalf("auto-own .zddc not written at working/ACME/: %v", err) } body := string(data) if !strings.Contains(body, "alice@x.com: rwcda") { @@ -39,127 +35,74 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) { t.Errorf("created_by missing: %s", body) } if strings.Contains(body, "inherit: false") { - t.Errorf("party working/ .zddc should be UNFENCED so party admins still reach inside; got: %s", body) + t.Errorf("working// .zddc should be UNFENCED; got: %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", "archive", "ACME", "working", "alice@x.com", ".zddc") - if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "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) + // The working/ peer root itself does NOT auto-own (auto_own is at the + // level). + if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) { + t.Errorf("working/ peer root should not have auto-own .zddc; got err=%v", err) } } -// staging// 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. +// staging/// is NOT auto-owned — only the level is. func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) { root := t.TempDir() - target := filepath.Join(root, "Proj", "archive", "ACME", "staging", + target := filepath.Join(root, "Proj", "staging", "ACME", "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//.zddc should not exist (only the parent staging/ - // gets an auto-own; the date-named child is plain). - childZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging", + childZddc := filepath.Join(root, "Proj", "staging", "ACME", "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) } - // And the staging/ slot itself gets the unfenced auto-own. - stagingZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging", ".zddc") + stagingZddc := filepath.Join(root, "Proj", "staging", "ACME", ".zddc") if _, err := os.Stat(stagingZddc); err != nil { - t.Errorf("party staging/ auto-own .zddc missing: %v", err) + t.Errorf("staging/ auto-own .zddc missing: %v", err) } } func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) { root := t.TempDir() - // Pre-create Archive/ (PascalCase) — case-fold reuse applies to - // the canonical project-root slot. - if err := os.MkdirAll(filepath.Join(root, "Proj", "Archive", "ACME", "working"), 0o755); err != nil { + // Pre-create Archive/ (PascalCase) — case-fold reuse applies to the + // canonical project-root peer + the received/issued slots. + if err := os.MkdirAll(filepath.Join(root, "Proj", "Archive", "ACME", "received"), 0o755); err != nil { t.Fatal(err) } - target := filepath.Join(root, "Proj", "archive", "ACME", "working", "foo.md") - resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) + target := filepath.Join(root, "Proj", "archive", "ACME", "received", "foo.pdf") + resolved, err := EnsureCanonicalAncestors(root, target, "dc@x.com", 0o755) if err != nil { t.Fatalf("ensure: %v", err) } - - // Resolved path uses on-disk Archive/ casing. - want := filepath.Join(root, "Proj", "Archive", "ACME", "working", "foo.md") + want := filepath.Join(root, "Proj", "Archive", "ACME", "received", "foo.pdf") if resolved != want { t.Errorf("resolved=%q, want %q", resolved, want) } - - // No new lowercase archive/ sibling. if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); !os.IsNotExist(err) { t.Errorf("lowercase sibling should not exist; got err=%v", err) } - - // Archive/ already existed — no auto-own .zddc was retroactively written. - if _, err := os.Stat(filepath.Join(root, "Proj", "Archive", ".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") + // incoming/ is a top-level peer; its folder auto-owns. + target := filepath.Join(root, "Proj", "incoming", "ACME", "submission.pdf") - _, err := EnsureCanonicalAncestors(root, target, "rep@acme.com", 0o755) - if err != nil { + if _, err := EnsureCanonicalAncestors(root, target, "rep@acme.com", 0o755); 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") + autoZ := filepath.Join(root, "Proj", "incoming", "ACME", ".zddc") data, err := os.ReadFile(autoZ) if err != nil { - t.Fatalf("auto-own .zddc at incoming/ not written: %v", err) + t.Fatalf("auto-own .zddc at incoming/ACME/ not written: %v", err) } if !strings.Contains(string(data), "rep@acme.com: rwcda") { - t.Errorf("incoming/ auto-own missing rep grant: %s", data) + t.Errorf("incoming/ACME auto-own missing rep grant: %s", data) + } + if strings.Contains(string(data), "inherit: false") { + t.Errorf("incoming/ACME auto-own should be UNFENCED; got: %s", data) } } @@ -167,49 +110,47 @@ 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 { + if _, err := EnsureCanonicalAncestors(root, target, "dc@mycompany.com", 0o755); 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) } + // archive/ and archive// are plain containers — no auto-own. + 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) + } } func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) { root := t.TempDir() - target := filepath.Join(root, "Proj", "archive", "ACME", "working", "anon.md") + target := filepath.Join(root, "Proj", "working", "ACME", "anon.md") - _, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755) - if err != nil { + if _, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755); err != nil { t.Fatalf("ensure: %v", err) } - - if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working")); err != nil { - t.Errorf("working/ not created: %v", err) + if _, err := os.Stat(filepath.Join(root, "Proj", "working", "ACME")); err != nil { + t.Errorf("working/ACME not created: %v", err) } - if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(root, "Proj", "working", "ACME", ".zddc")); !os.IsNotExist(err) { t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err) } } -// Project-root virtual aggregator names are rejected — a write -// targeting /working/<...> bypasses the virtual resolver -// and must not materialise on disk. -func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) { +// Top-level peers are physical now — a write under /// +// is created normally (no virtual-aggregator rejection). +func TestEnsureCanonicalAncestors_TopLevelPeersCreated(t *testing.T) { root := t.TempDir() - for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} { - target := filepath.Join(root, "Proj", slot, "x.md") - _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) - if err == nil { - t.Errorf("%s: expected error for write under /%s/, got nil", slot, slot) + for _, peer := range []string{"working", "staging", "reviewing", "incoming", "mdl", "rsk", "ssr"} { + target := filepath.Join(root, "Proj", peer, "ACME", "x.md") + if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil { + t.Errorf("%s: unexpected error: %v", peer, err) } - if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) { - t.Errorf("%s: /%s/ must NOT be created on disk; got err=%v", slot, slot, err) + if _, err := os.Stat(filepath.Join(root, "Proj", peer, "ACME")); err != nil { + t.Errorf("%s: /%s/ACME/ should be created; got err=%v", peer, peer, err) } } } @@ -218,8 +159,7 @@ 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 { + if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err == nil { t.Errorf("expected error for target outside fsRoot") } } diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index 992433b..35618fb 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -6,81 +6,69 @@ import ( "testing" ) -// TestDefaultToolAt_FromEmbeddedConvention — the canonical default- -// tool rules in defaults.zddc.yaml should resolve correctly for the -// well-known paths without any on-disk .zddc. -// -// Layout reshape: lifecycle slots (working/staging/reviewing) now -// live under archive//. The project-level -// /{working,staging,reviewing} URLs are virtual folder-nav -// aggregators (default_tool=browse). +// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-tool +// rules in defaults.zddc.yaml resolve correctly for the flat top-level +// peers (and their per-party subdirs) without any on-disk .zddc. func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() + j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) } cases := []struct { path string want string }{ - {filepath.Join(root, "Project-X", "archive"), "archive"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "tables"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working"), "browse"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), "browse"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "transmittal"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "browse"}, - // Project-level virtual aggregators. - {filepath.Join(root, "Project-X", "ssr"), "tables"}, - {filepath.Join(root, "Project-X", "mdl"), "tables"}, - {filepath.Join(root, "Project-X", "rsk"), "tables"}, - {filepath.Join(root, "Project-X", "working"), "browse"}, - {filepath.Join(root, "Project-X", "staging"), "browse"}, - {filepath.Join(root, "Project-X", "reviewing"), "browse"}, + // The committed record (archive subtree → archive tool). + {j("archive"), "archive"}, + {j("archive", "Acme"), "archive"}, + {j("archive", "Acme", "received"), "archive"}, + {j("archive", "Acme", "issued"), "archive"}, + // Top-level peers. + {j("ssr"), "tables"}, + {j("mdl"), "tables"}, + {j("rsk"), "tables"}, + {j("incoming"), "classifier"}, + {j("working"), "browse"}, + {j("staging"), "transmittal"}, + {j("reviewing"), "browse"}, + // Per-party subdirs inherit the peer's default_tool. + {j("mdl", "Acme"), "tables"}, + {j("rsk", "Acme"), "tables"}, + {j("incoming", "Acme"), "classifier"}, + {j("working", "Acme"), "browse"}, + {j("staging", "Acme"), "transmittal"}, + {j("reviewing", "Acme"), "browse"}, } for _, tc := range cases { - got := DefaultToolAt(root, tc.path) - if got != tc.want { - t.Errorf("DefaultToolAt(%q) = %q, want %q", - tc.path[len(root):], got, tc.want) + if got := DefaultToolAt(root, tc.path); got != tc.want { + t.Errorf("DefaultToolAt(%q) = %q, want %q", tc.path[len(root):], got, tc.want) } } } -// TestHistoryAt_Defaults — the embedded convention enables edit-history -// versioning on the per-party archive//working/ subtree (+ its -// homes). History is subtree-inheriting and ignores the homes' -// inherit:false fences. The project-level working/ aggregator is virtual -// (no direct content → no history), and sibling slots (staging, -// reviewing, mdl, incoming, received) do NOT get history. +// TestHistoryAt_Defaults — edit-history defaults on for the live-editing +// peers working/mdl/rsk and the ssr registry (subtree-inheriting). The +// other peers and the WORM archive do not get history. func TestHistoryAt_Defaults(t *testing.T) { resetCache() root := t.TempDir() + j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) } cases := []struct { path string want bool }{ - // Edit-history defaults on for the three live-editing slots: - // working, mdl, rsk — at both the project-level virtual nodes and - // the per-party folders (subtree-inheriting). - {filepath.Join(root, "Project-X", "working"), true}, - {filepath.Join(root, "Project-X", "working", "alice@example.com"), true}, - {filepath.Join(root, "Project-X", "mdl"), true}, - {filepath.Join(root, "Project-X", "rsk"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true}, - // Other slots get no history. - {filepath.Join(root, "Project-X", "staging"), false}, - {filepath.Join(root, "Project-X", "reviewing"), false}, - {filepath.Join(root, "Project-X", "ssr"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "received"), false}, - {filepath.Join(root, "Project-X", "archive"), false}, + {j("working"), true}, + {j("working", "Acme"), true}, + {j("working", "Acme", "notes"), true}, + {j("mdl"), true}, + {j("mdl", "Acme"), true}, + {j("rsk"), true}, + {j("ssr"), true}, + // No history elsewhere. + {j("staging"), false}, + {j("reviewing"), false}, + {j("incoming"), false}, + {j("archive"), false}, + {j("archive", "Acme", "received"), false}, } for _, tc := range cases { if got := HistoryAt(root, tc.path); got != tc.want { @@ -89,25 +77,22 @@ func TestHistoryAt_Defaults(t *testing.T) { } } -// TestDirToolAt — the trailing-slash form floors at "browse" for -// every path (the embedded convention sets dir_tool nowhere), and an -// on-disk .zddc can override it for a subtree. +// TestDirToolAt — the trailing-slash form floors at "browse" for every +// path (the embedded convention sets dir_tool nowhere), and an on-disk +// .zddc can override it for a subtree. func TestDirToolAt(t *testing.T) { resetCache() root := t.TempDir() - // Nothing declares dir_tool → browse everywhere, including paths - // whose default_tool (no-slash form) is something else. for _, p := range []string{ filepath.Join(root, "Project-X"), - filepath.Join(root, "Project-X", "archive", "Acme", "working"), - filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), + filepath.Join(root, "Project-X", "working", "Acme"), + filepath.Join(root, "Project-X", "mdl", "Acme"), filepath.Join(root, "Project-X", "random", "deep", "folder"), } { if got := DirToolAt(root, p); got != "browse" { t.Errorf("DirToolAt(%q) = %q, want browse", p[len(root):], got) } } - // Operator override on a subtree; cascades leaf→root. specialDir := filepath.Join(root, "Special") if err := os.MkdirAll(specialDir, 0o755); err != nil { t.Fatal(err) @@ -125,237 +110,116 @@ func TestDirToolAt(t *testing.T) { } } -// TestCanonicalFolderAt — structural detection of the canonical -// project-layout slots that the browse SPA scope-gates context-menu -// actions against. -// -// After the layout reshape: -// - /archive is the only depth-2 canonical -// - /archive// covers the eight per-party -// physical slots (incoming, received, issued, mdl, rsk, working, -// staging, reviewing) -// - everything else returns "" +// TestCanonicalFolderAt — structural detection of the canonical slots the +// browse SPA scope-gates context-menu actions against, in the flat-peer +// layout: depth-2 peers, depth-3 / (workspace/register peers +// report their peer), and depth-4 archive//{received,issued}. func TestCanonicalFolderAt(t *testing.T) { resetCache() root := t.TempDir() + j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) } cases := []struct { path string want string }{ - {filepath.Join(root, "Project-X", "archive"), "archive"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "incoming"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "received"), "received"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "issued"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "mdl"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "rsk"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working"), "working"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "staging"}, - {filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "reviewing"}, - // Project-root virtuals are NOT canonical-folder slots. - {filepath.Join(root, "Project-X", "working"), ""}, - {filepath.Join(root, "Project-X", "staging"), ""}, - {filepath.Join(root, "Project-X", "reviewing"), ""}, + {j("archive"), "archive"}, + {j("ssr"), "ssr"}, + {j("mdl"), "mdl"}, + {j("rsk"), "rsk"}, + {j("incoming"), "incoming"}, + {j("working"), "working"}, + {j("staging"), "staging"}, + {j("reviewing"), "reviewing"}, + // / reports the peer (not archive). + {j("mdl", "Acme"), "mdl"}, + {j("working", "Acme"), "working"}, + {j("incoming", "Acme"), "incoming"}, + // archive/ is not itself a slot; received/issued are. + {j("archive", "Acme"), ""}, + {j("archive", "Acme", "received"), "received"}, + {j("archive", "Acme", "issued"), "issued"}, + // Non-slots. {root, ""}, - {filepath.Join(root, "Project-X"), ""}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), ""}, - {filepath.Join(root, "Project-X", "archive", "Acme"), ""}, - {filepath.Join(root, "Project-X", "archive", "Acme", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""}, - {filepath.Join(root, "Project-X", "random", "dir"), ""}, + {j(), ""}, + {j("mdl", "Acme", "sub"), ""}, + {j("archive", "Acme", "received", "Acme-0042"), ""}, + {j("random", "dir"), ""}, } for _, tc := range cases { - got := CanonicalFolderAt(root, tc.path) - if got != tc.want { - t.Errorf("CanonicalFolderAt(%q) = %q, want %q", - tc.path[len(root):], got, tc.want) + if got := CanonicalFolderAt(root, tc.path); got != tc.want { + t.Errorf("CanonicalFolderAt(%q) = %q, want %q", tc.path[len(root):], got, tc.want) } } } -// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for -// the per-party lifecycle slots (working/staging/reviewing/incoming) -// and false for received/issued/mdl/rsk. +// TestAutoOwnAt_FromEmbeddedConvention — auto_own is declared at the +// level of the workspace peers (incoming/working/staging/ +// reviewing); the registers (mdl/rsk/ssr) and the WORM archive don't +// auto-own. func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() + j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) } cases := []struct { path string want bool }{ - {filepath.Join(root, "Project-X", "archive", "Acme", "working"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "staging"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "received"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), false}, + {j("working", "Acme"), true}, + {j("staging", "Acme"), true}, + {j("reviewing", "Acme"), true}, + {j("incoming", "Acme"), true}, + {j("mdl", "Acme"), false}, + {j("rsk", "Acme"), false}, + {j("archive", "Acme", "received"), false}, + {j("archive", "Acme", "issued"), false}, } for _, tc := range cases { - got := AutoOwnAt(root, tc.path) - if got != tc.want { - t.Errorf("AutoOwnAt(%q) = %v, want %v", - tc.path[len(root):], got, tc.want) + if got := AutoOwnAt(root, tc.path); got != tc.want { + t.Errorf("AutoOwnAt(%q) = %v, want %v", tc.path[len(root):], got, tc.want) } } } -// TestVirtualAt_FromEmbeddedConvention — mdl/rsk under a party are -// declared virtual, and the six project-level aggregators are virtual. -// Other canonical slots materialise on disk. +// TestVirtualAt_FromEmbeddedConvention — the flat-peer layout has no +// virtual: directories (every peer is physical), so VirtualAt is false +// everywhere unless an operator sets it. func TestVirtualAt_FromEmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() - cases := []struct { - path string - want bool - }{ - {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false}, - {filepath.Join(root, "Project-X", "archive", "Acme", "received"), false}, - // Project-level aggregators. - {filepath.Join(root, "Project-X", "ssr"), true}, - {filepath.Join(root, "Project-X", "mdl"), true}, - {filepath.Join(root, "Project-X", "rsk"), true}, - {filepath.Join(root, "Project-X", "working"), true}, - {filepath.Join(root, "Project-X", "staging"), true}, - {filepath.Join(root, "Project-X", "reviewing"), true}, - } - for _, tc := range cases { - got := VirtualAt(root, tc.path) - if got != tc.want { - t.Errorf("VirtualAt(%q) = %v, want %v", - tc.path[len(root):], got, tc.want) + j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) } + for _, p := range []string{ + j("ssr"), j("mdl"), j("rsk"), j("working"), j("staging"), j("reviewing"), j("incoming"), + j("mdl", "Acme"), j("working", "Acme"), j("archive", "Acme", "received"), + } { + if VirtualAt(root, p) { + t.Errorf("VirtualAt(%q) = true, want false (no virtual peers)", p[len(root):]) } } } -// TestIsDeclaredPath_FromEmbeddedConvention — canonical paths under -// the convention are declared even on a fresh root; arbitrary paths -// are not. +// TestIsDeclaredPath_FromEmbeddedConvention — the top-level peers are +// cascade-declared even on a fresh root; arbitrary names are not. func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() + j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) } cases := []struct { path string want bool }{ - {filepath.Join(root, "Project-X", "archive"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true}, - {filepath.Join(root, "Project-X", "archive", "Acme", "working"), true}, - // Project-root aggregators are also declared. - {filepath.Join(root, "Project-X", "working"), true}, - {filepath.Join(root, "Project-X", "reviewing"), true}, - {filepath.Join(root, "Project-X", "ssr"), true}, - {filepath.Join(root, "Project-X", "junk"), false}, // not in convention + {j("archive"), true}, + {j("ssr"), true}, + {j("incoming"), true}, + {j("working"), true}, + {j("staging"), true}, + {j("reviewing"), true}, + {j("mdl"), true}, + {j("rsk"), true}, + {j("junk"), false}, } for _, tc := range cases { - got := IsDeclaredPath(root, tc.path) - if got != tc.want { - t.Errorf("IsDeclaredPath(%q) = %v, want %v", - tc.path[len(root):], got, tc.want) + if got := IsDeclaredPath(root, tc.path); got != tc.want { + t.Errorf("IsDeclaredPath(%q) = %v, want %v", tc.path[len(root):], got, tc.want) } } } - -// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project root -// the cascade declares archive/ plus the six top-level virtual -// aggregator slots (ssr, mdl, rsk, working, staging, reviewing). -func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) { - resetCache() - root := t.TempDir() - got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X")) - want := map[string]bool{ - "archive": true, - "ssr": true, "mdl": true, "rsk": true, - "working": true, "staging": true, "reviewing": true, - } - if len(got) != len(want) { - t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want) - } - for _, n := range got { - if !want[n] { - t.Errorf("unexpected child %q", n) - } - } -} - -// TestOperatorOverride_DefaultsAreSurfaceable — operator can override -// any of the canonical tool defaults by mirroring the structure in an -// on-disk .zddc. The override wins. -func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) { - resetCache() - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "Special", "archive", "Acme", "working"), 0o755); err != nil { - t.Fatal(err) - } - // Operator declares that Special/archive/Acme/working uses - // classifier instead of the embedded-default browse. - writeZddc(t, filepath.Join(root, "Special", "archive", "Acme", "working"), - "default_tool: classifier\n") - - if got := DefaultToolAt(root, filepath.Join(root, "Special", "archive", "Acme", "working")); got != "classifier" { - t.Errorf("operator override should set default_tool=classifier, got %q", got) - } - // Default still applies at other projects. - if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "archive", "Acme", "working")); got != "browse" { - t.Errorf("default convention should hold at unchanged paths, got %q", got) - } -} - -// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets -// default_tool, descendants inherit it unless they override. So a -// path under working/ that isn't explicitly declared in paths: still -// gets browse as its default tool. -func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) { - resetCache() - root := t.TempDir() - // Deep path under archive//working/ — not explicitly - // mentioned in paths:. - deep := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes", "sub", "deep") - if got := DefaultToolAt(root, deep); got != "browse" { - t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)", - deep[len(root):], got) - } -} - -// TestAutoOwnAt_DescendantCanDisable — explicit auto_own:false at a -// descendant overrides an ancestor's auto_own:true. -func TestAutoOwnAt_DescendantCanDisable(t *testing.T) { - resetCache() - root := t.TempDir() - deepDir := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com") - if err := os.MkdirAll(deepDir, 0o755); err != nil { - t.Fatal(err) - } - writeZddc(t, deepDir, "auto_own: false\n") - if got := AutoOwnAt(root, deepDir); got != false { - t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got) - } - // Ancestor still has it true. - ancestor := filepath.Join(root, "Project-X", "archive", "Acme", "working") - if got := AutoOwnAt(root, ancestor); got != true { - t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got) - } -} - -// TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root, -// inherit:false stops the embedded layer from contributing. The -// canonical paths are then no longer declared. -func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) { - resetCache() - root := t.TempDir() - writeZddc(t, root, "inherit: false\n") - // Without the embedded defaults' paths: tree, IsDeclaredPath - // returns false for previously-canonical paths. - if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) { - t.Errorf("with inherit:false at root, archive should not be a declared path") - } - if DefaultToolAt(root, filepath.Join(root, "Project-X", "archive", "Acme", "working")) != "" { - t.Errorf("with inherit:false at root, default_tool should be empty for working") - } -} diff --git a/zddc/internal/zddc/standardroles_test.go b/zddc/internal/zddc/standardroles_test.go index f3e275c..b880dee 100644 --- a/zddc/internal/zddc/standardroles_test.go +++ b/zddc/internal/zddc/standardroles_test.go @@ -25,35 +25,21 @@ import ( func TestStandardRoles_DocControllerScopedCreate(t *testing.T) { resetCache() root := t.TempDir() - // DCs are typically internal employees and ARE in project_team - // (which is commonly defined as the *@example.com wildcard). The - // embedded defaults restate document_controller:rwcda at every - // slot that grants project_team a narrower verb set; the - // cascade's within-level union then gives the DC the higher - // grant. This fixture mirrors the realistic deployment shape so - // the union behavior is actually exercised. + // DC authority comes PURELY from the cascade peer grants in + // defaults.zddc.yaml — no auto-own / admins: list. DCs are typically + // in project_team too (the *@example.com wildcard); the defaults + // restate document_controller at each peer so the within-level union + // gives the DC the higher grant. writeZddc(t, root, `roles: document_controller: members: ["dc@example.com"] project_team: members: ["*@example.com"] -`) - // Simulate the auto-own .zddc the file API writes when DC mkdir's - // archive/Acme/. Carries the creator email + the document_controller - // role per the embedded defaults' auto_own_roles entry. - partyDir := filepath.Join(root, "Proj", "archive", "Acme") - if err := os.MkdirAll(partyDir, 0o755); err != nil { - t.Fatal(err) - } - writeZddc(t, partyDir, `acl: - permissions: - "dc@example.com": rwcda - document_controller: rwcda -created_by: dc@example.com `) resetCache() dc := "dc@example.com" + j := func(p ...string) string { return filepath.Join(append([]string{root, "Proj"}, p...)...) } mustVerbs := func(dir string, want string) { t.Helper() @@ -73,61 +59,29 @@ created_by: dc@example.com } // Project level: rw (no c). - mustVerbs(filepath.Join(root, "Proj"), "rw") - // A random subfolder under the project inherits rw (no c). - mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw") - // archive/: rwc (can create party folders). - mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc") - // At the party folder itself: rwcda via the auto-own role grant. - mustVerbs(partyDir, "rwcda") - // Lifecycle slots inside the party folder inherit rwcda from the - // party-level role grant where no slot-local grant overrides. - mustVerbs(filepath.Join(partyDir, "working"), "rwcda") - mustVerbs(filepath.Join(partyDir, "reviewing"), "rwcda") - // incoming/ has explicit document_controller: rwcd - // — leaf-wins shadows the rwcda inherited from /. - mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd") - // staging/ has explicit document_controller: rwcda (rwcd for - // transfer + `a` for Plan Review's staging//.zddc). - mustVerbs(filepath.Join(partyDir, "staging"), "rwcda") - // received/ (WORM): inherited rwcda masked to r + worm-restored c. - mustVerbs(filepath.Join(partyDir, "received"), "rc") - mustVerbs(filepath.Join(partyDir, "issued"), "rc") + mustVerbs(j(), "rw") + mustVerbs(j("random-folder"), "rw") + // archive subtree is WORM: read + worm-create only (w/d/a stripped). + mustVerbs(j("archive"), "rc") + mustVerbs(j("archive", "Acme"), "rc") + mustVerbs(j("archive", "Acme", "received"), "rc") + mustVerbs(j("archive", "Acme", "issued"), "rc") + // Workspace peers: full authority via the peer-level DC grant. + mustVerbs(j("working", "Acme"), "rwcda") + mustVerbs(j("staging", "Acme"), "rwcda") + mustVerbs(j("reviewing", "Acme"), "rwcda") + mustVerbs(j("incoming", "Acme"), "rwcd") + // Registers. + mustVerbs(j("mdl", "Acme"), "rwcd") + mustVerbs(j("rsk", "Acme"), "rwcd") + mustVerbs(j("ssr"), "rwc") - // NOT subtree-admin anywhere — even when notionally elevated, - // the role carries no admin: grant. - for _, p := range []string{ - filepath.Join(root, "Proj"), - filepath.Join(root, "Proj", "archive"), - partyDir, - filepath.Join(partyDir, "working"), - filepath.Join(partyDir, "staging"), - filepath.Join(partyDir, "reviewing"), - filepath.Join(partyDir, "received"), - filepath.Join(partyDir, "issued"), - } { + // NOT subtree-admin anywhere — no admins: grant for the role. + for _, p := range []string{j(), j("archive"), j("working", "Acme"), j("ssr"), j("mdl", "Acme")} { if IsSubtreeAdmin(root, p, Principal{Email: dc, Elevated: true}) { t.Errorf("doc controller should NOT be subtree-admin of %s (no admins: list anywhere)", p[len(root):]) } } - // And specifically — they CAN'T reach inside a fenced per-user - // working home. The fence isolates team-member workspaces from - // every other role (including DC) by design. - homeDir := filepath.Join(partyDir, "working", "alice@example.com") - if err := os.MkdirAll(homeDir, 0o755); err != nil { - t.Fatal(err) - } - writeZddc(t, homeDir, `acl: - inherit: false - permissions: - "alice@example.com": rwcda -created_by: alice@example.com -`) - resetCache() - chain, _ := EffectivePolicy(root, homeDir) - if got := EffectiveVerbs(chain, dc); got != 0 { - t.Errorf("doc controller inside alice's fenced home = %q, want empty (fence isolates)", got.String()) - } } // TestStandardRoles_DocControllerMultiDC — a second DC added to the @@ -255,13 +209,13 @@ func TestStandardRoles_ProjectTeamInFlightRatchet(t *testing.T) { } } - party := filepath.Join(root, "Proj", "archive", "Acme") - mustVerbs(filepath.Join(party, "working"), "rc") // create + read at slot - mustVerbs(filepath.Join(party, "staging"), "rc") // drop + read, no modify - mustVerbs(filepath.Join(party, "reviewing"), "rc") // create iteration folders - mustVerbs(filepath.Join(party, "received"), "r") // WORM — read pass-through, no worm-create - mustVerbs(filepath.Join(party, "issued"), "r") // WORM — same - mustVerbs(filepath.Join(party, "incoming"), "r") // counterparty drop zone — read only + j := func(p ...string) string { return filepath.Join(append([]string{root, "Proj"}, p...)...) } + mustVerbs(j("working", "Acme"), "rc") // create + read at the workspace + mustVerbs(j("staging", "Acme"), "rc") // drop + read, no modify + mustVerbs(j("reviewing", "Acme"), "rc") // create iteration folders + mustVerbs(j("archive", "Acme", "received"), "r") // WORM — read pass-through + mustVerbs(j("archive", "Acme", "issued"), "r") // WORM — same + mustVerbs(j("incoming", "Acme"), "r") // counterparty drop zone — read only } // TestStandardRoles_DocControllerStagingDelete — DC needs `d` at @@ -277,7 +231,7 @@ func TestStandardRoles_DocControllerStagingDelete(t *testing.T) { members: ["dc@example.com"] `) dc := "dc@example.com" - chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "staging")) + chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "staging", "Acme")) if err != nil { t.Fatalf("EffectivePolicy: %v", err) } diff --git a/zddc/internal/zddc/worm_test.go b/zddc/internal/zddc/worm_test.go index ee31e7c..c658a81 100644 --- a/zddc/internal/zddc/worm_test.go +++ b/zddc/internal/zddc/worm_test.go @@ -6,10 +6,11 @@ import ( "testing" ) -// TestWormZoneGrant_EmbeddedConvention — archive//received and -// issued carry `worm: []` in defaults.zddc.yaml, so any path under -// those folders is a WORM zone (inWorm=true) with no create-capable -// principals (grant=0). Other paths are not WORM zones. +// TestWormZoneGrant_EmbeddedConvention — defaults.zddc.yaml declares +// `worm: [document_controller]` on archive/, so the ENTIRE archive +// subtree is a WORM zone (inWorm=true). With no role members in this +// bare fixture the grant for an arbitrary principal is 0. The top-level +// workspace/register peers are NOT under archive and are not WORM. func TestWormZoneGrant_EmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() @@ -18,13 +19,16 @@ func TestWormZoneGrant_EmbeddedConvention(t *testing.T) { path string wantInWorm bool }{ + {filepath.Join(root, "Proj", "archive"), true}, + {filepath.Join(root, "Proj", "archive", "Acme"), true}, {filepath.Join(root, "Proj", "archive", "Acme", "received"), true}, {filepath.Join(root, "Proj", "archive", "Acme", "issued"), true}, - {filepath.Join(root, "Proj", "archive", "Acme", "received", "2025-Q1"), true}, // deeper still WORM - {filepath.Join(root, "Proj", "archive", "Acme", "incoming"), false}, - {filepath.Join(root, "Proj", "archive", "Acme", "mdl"), false}, + {filepath.Join(root, "Proj", "archive", "Acme", "received", "2025-Q1"), true}, + {filepath.Join(root, "Proj", "incoming"), false}, + {filepath.Join(root, "Proj", "mdl"), false}, {filepath.Join(root, "Proj", "working"), false}, {filepath.Join(root, "Proj", "staging"), false}, + {filepath.Join(root, "Proj", "ssr"), false}, } for _, tc := range cases { chain, err := EffectivePolicy(root, tc.path)