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//working// (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 / 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// — 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//. 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 /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 / 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) } } }