Listing JSON gains a writable bool per file row, computed by running the policy decider with ActionWrite against the parent-dir chain (with the same admin-bypass branch the file API uses). Cost: one extra decider call per file in the listing, sharing the parent chain so the cascade walk is amortized. Browse loader stores writable on every tree node. The markdown and YAML editors read it and gate their canSave + initial mount: - !writable markdown → Toast UI Viewer (rendered, no edit toolbar, no caret). Banner above explains why save is disabled. - !writable YAML → CodeMirror readOnly:'nocursor' (selection for copy, no caret). Banner above explains why save is disabled. Both editors gain autofocus:false so keyboard nav in the browse tree doesn't divert into the editor — arrow keys keep moving through files and folders without the caret jumping. User clicks (or tabs) into the editor when they actually want to type. .zddc files already route through preview-yaml's isZddcFile path; bare .zddc (no ext) matches because that function checks the literal name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
6.4 KiB
Go
206 lines
6.4 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
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "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 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", "working", "Alice@Example.com"), 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)
|
|
}
|
|
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", "working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/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", "staging"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/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/ at depth 3+ — no synthetic entry should fire.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "alice@example.com"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/working/alice@example.com", "alice@example.com",
|
|
"/Proj/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/.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "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 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 project folder that doesn't exist on disk yet
|
|
// returns an empty slice instead of os.ErrNotExist. The stage-strip
|
|
// nav links into <project>/working/ etc. unconditionally; this keeps
|
|
// fresh projects (no working/ on disk yet) from 404'ing.
|
|
func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// Proj exists but Proj/working/ does NOT.
|
|
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 _, stage := range []string{"working", "staging", "reviewing", "archive"} {
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false)
|
|
if err != nil {
|
|
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, 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(Proj/working) on missing dir: got %+v, want only the virtual home entry", got)
|
|
}
|
|
} else {
|
|
if len(got) != 0 {
|
|
t.Errorf("ListDirectory(Proj/%s) on missing dir: got %+v, want empty", stage, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback
|
|
// only applies to the four canonical project-root folders.
|
|
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)
|
|
}
|
|
}
|