Add a `verbs` field (canonical "rwcda" subset) to every directory
listing entry, computed via a new
`policy.EffectiveVerbsFromChainP(ctx, d, chain, p, path)` helper that
routes each of the five actions through the decider and unions the
allowed bits — so an external OPA's overrides surface in the wire
field, and active-admin elevation produces the full grant.
Semantics:
- file entry: verbs from the parent dir's chain (files inherit;
they have no .zddc of their own). Same chain Writable uses.
- directory entry: verbs from the subdir's OWN chain, so a fenced
or extended .zddc inside it shows through.
- virtual entries (auto-own homes, canonical-folder placeholders,
workflow received/ window, table.yaml/form.yaml spec rows):
verbs computed against the would-be path's chain so client
affordances render correctly before any write materialises a
real folder.
Writable stays in lockstep with verbs for the transition window so
existing clients (markdown/yaml editor save buttons) keep working
unchanged. Clients should migrate to checking 'w' in verbs and let
Writable wither.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
357 lines
12 KiB
Go
357 lines
12 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
|
|
}
|
|
|
|
// Per-user homes now live at archive/<party>/working/<email>/ (depth-
|
|
// 4). The virtual entry fires when listing that path for a viewer
|
|
// whose home doesn't yet exist on disk.
|
|
|
|
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/archive/Acme/working", "alice@example.com",
|
|
"/Proj/archive/Acme/working/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
|
|
var virtual *string
|
|
for i := range got {
|
|
if got[i].Virtual {
|
|
n := got[i].Name
|
|
virtual = &n
|
|
}
|
|
}
|
|
if virtual == nil {
|
|
t.Fatalf("expected synthetic <viewer-email>/ entry, got entries: %+v", got)
|
|
}
|
|
if *virtual != "alice@example.com/" {
|
|
t.Errorf("synthetic name = %q, want alice@example.com/", *virtual)
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// A real folder exists for the viewer (any case).
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "Alice@Example.com"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/archive/Acme/working", "alice@example.com",
|
|
"/Proj/archive/Acme/working/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
t.Errorf("synthetic entry should be suppressed when a case-fold match exists; got %+v", fi)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/archive/Acme/working", "" /* no viewer */,
|
|
"/Proj/archive/Acme/working/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
t.Errorf("anonymous viewer should not see synthetic entries; got %+v", fi)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "staging"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/archive/Acme/staging", "alice@example.com",
|
|
"/Proj/archive/Acme/staging/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
t.Errorf("staging/ should not have a synthetic user-home entry; got %+v", fi)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// Listing inside working/<email>/ — no synthetic entry should fire.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/archive/Acme/working/alice@example.com", "alice@example.com",
|
|
"/Proj/archive/Acme/working/alice@example.com/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
t.Errorf("nested working/ subdir must not synthesise the user home; got %+v", fi)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// Pre-existing PascalCase Working/ under archive/<party>/.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "Working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/archive/Acme/Working", "alice@example.com",
|
|
"/Proj/archive/Acme/Working/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
var found bool
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("PascalCase Working/ should still surface the synthetic entry; got entries: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Listing a canonical-folder path that doesn't exist on disk yet
|
|
// returns an empty slice instead of os.ErrNotExist. The stage-strip
|
|
// nav links into <project>/archive/ etc. unconditionally; this keeps
|
|
// fresh projects from 404'ing.
|
|
//
|
|
// The synthetic per-user home entry fires for the in-party working
|
|
// slot; other canonical slots return a plain empty listing.
|
|
func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// Proj exists; the party folder skeleton does not.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme"), 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 _, stage := range []string{"working", "staging", "reviewing", "incoming"} {
|
|
dirPath := "Proj/archive/Acme/" + stage
|
|
baseURL := "/" + dirPath + "/"
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
dirPath, "alice@example.com", baseURL, false, false)
|
|
if err != nil {
|
|
t.Errorf("ListDirectory(%s) on missing dir: err = %v, want nil", dirPath, err)
|
|
continue
|
|
}
|
|
// working/ surfaces a synthetic <viewer-email>/ entry; the
|
|
// others should be a flat empty listing.
|
|
if stage == "working" {
|
|
if len(got) != 1 || !got[0].Virtual {
|
|
t.Errorf("ListDirectory(%s) on missing dir: got %+v, want only the virtual home entry", dirPath, got)
|
|
}
|
|
} else {
|
|
if len(got) != 0 {
|
|
t.Errorf("ListDirectory(%s) on missing dir: 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)
|
|
}
|
|
}
|
|
|
|
// Project-level folder-nav virtual lists only the parties that have
|
|
// non-empty content in the slot. Empty/missing party slots are
|
|
// filtered out.
|
|
func TestListDirectory_VirtualFolderNav_FiltersInFlight(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// Acme has a populated working/; Beta is scaffolded but empty.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Proj", "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Beta", "working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
var partyDirs []string
|
|
for _, fi := range got {
|
|
if fi.IsDir && fi.Virtual {
|
|
partyDirs = append(partyDirs, fi.Name)
|
|
}
|
|
}
|
|
want := []string{"Acme/"}
|
|
if len(partyDirs) != 1 || partyDirs[0] != want[0] {
|
|
t.Errorf("project-level folder-nav listing = %v, want %v", partyDirs, want)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
// Writable stays in lockstep with verbs for the transition
|
|
// window — w bit for files, r/c semantics for dirs (no
|
|
// Writable on dirs today; we don't assert it).
|
|
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)
|
|
|
|
// Elevated admin sees rwcda everywhere.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Same admin un-elevated sees nothing (no explicit ACL grant,
|
|
// admin bypass disabled).
|
|
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)
|
|
}
|
|
}
|
|
}
|