browse: the party picker reads the ssr/ registry (the authoritative party list) and creates at physical peer paths <project>/<peer>/<party>/…; "register new party" writes ssr/<party>.yaml first (party_source: ssr). stage.js + accept-transmittal.js repointed to the top-level workspace peers (working/staging/incoming) — received/issued + plan-review stay under the WORM archive. tables: mdl/ and rsk/ render the cross-party aggregate by recursing ONE level into the party subdirs CLIENT-side (works online AND offline), with $party from the server-injected row content (or derived from the subdir offline). Rows carry the <party>/ prefix so reads/edits hit the real per-party path. The server just lists the peer root normally (party subdirs + synthetic table.yaml/form.yaml) — the fs/tree flattening + ListRollupRows are dropped in favour of this dual-mode client recursion. Full Go suite + all 256 Playwright tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
6.5 KiB
Go
198 lines
6.5 KiB
Go
package fs
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
func setupTreeRoot(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
// Permissive root .zddc so subdirectory ACL checks pass.
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
return root
|
|
}
|
|
|
|
// Listing a cascade-declared peer that doesn't exist on disk yet returns
|
|
// an empty slice instead of os.ErrNotExist, so fresh projects don't 404.
|
|
// (The tables peers mdl/rsk/ssr instead surface their synthetic spec
|
|
// entries; see TestListDirectory_MdlAggregate.)
|
|
func TestListDirectory_DeclaredPeerEmptyWhenMissing(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
for _, peer := range []string{"working", "staging", "reviewing", "incoming"} {
|
|
dirPath := "Proj/" + peer
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
dirPath, "alice@example.com", "/"+dirPath+"/", false, false)
|
|
if err != nil {
|
|
t.Errorf("ListDirectory(%s) on missing peer: err = %v, want nil", dirPath, err)
|
|
continue
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("ListDirectory(%s) on missing peer: got %+v, want empty", dirPath, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback
|
|
// only applies to cascade-declared paths.
|
|
func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
_, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/random-folder-that-doesnt-exist", "alice@example.com",
|
|
"/Proj/random-folder-that-doesnt-exist/", false, false)
|
|
if !os.IsNotExist(err) {
|
|
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
|
|
}
|
|
}
|
|
|
|
// The mdl/ peer root lists its party subdirs (folder-nav) plus the
|
|
// synthetic table.yaml/form.yaml spec entries. The tables tool builds
|
|
// the cross-party aggregate by recursing into the party subdirs
|
|
// client-side; the server just lists normally + advertises the spec.
|
|
func TestListDirectory_MdlAggregate(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
mk := func(p string) {
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte("id: x\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
mk(filepath.Join(root, "Proj", "mdl", "Acme", "D-001.yaml"))
|
|
mk(filepath.Join(root, "Proj", "mdl", "Beta", "D-009.yaml"))
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/mdl", "alice@example.com", "/Proj/mdl/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
names := map[string]bool{}
|
|
for _, fi := range got {
|
|
names[fi.Name] = true
|
|
}
|
|
for _, want := range []string{"Acme/", "Beta/", "table.yaml", "form.yaml"} {
|
|
if !names[want] {
|
|
t.Errorf("mdl/ listing missing %q; got %+v", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestListDirectory_VerbsPerEntry — every entry in a directory listing
|
|
// carries `verbs`, the canonical "rwcda" subset granted to the caller
|
|
// at that entry's URL. Files and dirs are gated against different
|
|
// chains (files use parent's, dirs use their own), so a fenced subdir
|
|
// surfaces a different verb set than its file siblings.
|
|
func TestListDirectory_VerbsPerEntry(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Root grants alice read across the project; bob nothing.
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"alice@example.com\": rw\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Proj", "doc.md"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Subdir extends alice's grant to include create — confirms the
|
|
// dir entry's verbs come from its OWN chain, not parent's.
|
|
if err := os.WriteFile(filepath.Join(root, "Proj", "sub", ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"alice@example.com\": rwc\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj", "alice@example.com", "/Proj/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
wantVerbs := map[string]string{
|
|
"doc.md": "rw", // file: parent chain (project root → rw)
|
|
"sub/": "rwc", // dir: own chain (extends to rwc)
|
|
}
|
|
for _, fi := range got {
|
|
want, ok := wantVerbs[fi.Name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if fi.Verbs != want {
|
|
t.Errorf("entry %s verbs = %q, want %q", fi.Name, fi.Verbs, want)
|
|
}
|
|
if !fi.IsDir {
|
|
wantWritable := want == "rw"
|
|
if fi.Writable != wantWritable {
|
|
t.Errorf("entry %s Writable = %v, want %v", fi.Name, fi.Writable, wantWritable)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestListDirectory_VerbsActiveAdminBypass — an elevated admin sees the
|
|
// full "rwcda" verb set on every entry regardless of explicit ACL
|
|
// grants. Mirrors the InternalDecider's single bypass branch.
|
|
func TestListDirectory_VerbsActiveAdminBypass(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("admins:\n - admin@example.com\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Proj", "doc.md"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj", "admin@example.com", "/Proj/", false, true /* elevated */)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Verbs != "rwcda" {
|
|
t.Errorf("elevated admin %s verbs = %q, want rwcda", fi.Name, fi.Verbs)
|
|
}
|
|
}
|
|
|
|
got, err = ListDirectory(context.Background(), nil, root,
|
|
"Proj", "admin@example.com", "/Proj/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list un-elevated: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Verbs == "rwcda" {
|
|
t.Errorf("un-elevated admin %s verbs = %q, should not be full grant", fi.Name, fi.Verbs)
|
|
}
|
|
}
|
|
}
|