ZDDC/zddc/internal/fs/tree_test.go
ZDDC 72c0552750 feat(browse): "Show hidden" toggle — list .-prefixed and _-prefixed entries
Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.

  ┌─ browse toolbar ─────────────────────────────────────────────┐
  │  Sort: [Name (A→Z) ▾]    ☐ Show hidden                       │
  └──────────────────────────────────────────────────────────────┘

Server-side surface:

  - internal/fs/tree.go ListDirectory gains an `includeHidden bool`
    parameter. The .-prefix filter (previously hard-coded) now also
    drops _-prefix entries (matches dispatch's reserved-prefix guard)
    and honors the new flag.
  - internal/handler/directory.go reads `?hidden=1` from the request
    and threads it through.
  - cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
    _-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
    a hidden entry's link works. `_app/` (apps cache) stays
    unconditionally reserved — those bytes must go through the apps
    resolver. Writes to hidden paths stay blocked (the file API has
    its own segment check that the flag does NOT relax).
  - internal/listing/listing.go: signature parity (the lower-level
    helper that's used by tests + non-cascade listing paths).

Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:

  • <dir>/.zddc                ACL YAML — already exposed via /.profile/zddc
  • <dir>/.converted/<base>    cached MD→DOCX/HTML/PDF, same sensitivity as source
  • <root>/.zddc.d/tokens/     per-token metadata; filename = sha256(token)
                               so not bearer-usable. Default root ACL
                               restricts to admins; matches /.tokens UI.
  • <root>/.zddc.d/logs/       access logs; same admins-only audience
  • <root>/_app/               cached upstream tool HTML (public)
  • <root>/_template/          install.zip scaffolding (public)

None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
2026-05-13 14:45:41 -05:00

206 lines
6.3 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)
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)
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)
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)
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)
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)
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)
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)
if !os.IsNotExist(err) {
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
}
}