Generalize the admin model from "single root super-admin" to a delegated chain: a `<dir>/.zddc/admins` list grants admin authority for that subtree, with a strict-ancestor rule preventing self-elevation (you cannot edit the .zddc that grants your own authority — only files strictly below it). Add a guided server-rendered editor at /.admin/zddc/edit?path=<dir> so subtree admins can manage their fiefdoms without filesystem access. JSON API at /.admin/zddc covers GET (file + effective chain + can_edit), POST (atomic write + cache invalidation), DELETE, plus a /tree endpoint listing every .zddc visible to the caller. Optional theming via <root>/.admin.css. Validation: glob syntax check, root-self-demotion rejection, reserved-prefix path guard, YAML round-trip sanity. Writes are atomic (temp file + fsync + rename) and invalidate the policy cache. Also includes the prior in-flight `Title` field on ProjectInfo so per-project .zddc titles surface on the landing-page picker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
3.9 KiB
Go
145 lines
3.9 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestWriteFileRoundTrip(t *testing.T) {
|
|
root := t.TempDir()
|
|
in := ZddcFile{
|
|
Title: "Greenfield Substation",
|
|
ACL: ACLRules{
|
|
Allow: []string{"*@varasys.io"},
|
|
Deny: []string{"intern@varasys.io"},
|
|
},
|
|
Admins: []string{"alice@varasys.io"},
|
|
}
|
|
|
|
if err := WriteFile(root, in); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
out, err := ParseFile(filepath.Join(root, ".zddc"))
|
|
if err != nil {
|
|
t.Fatalf("ParseFile: %v", err)
|
|
}
|
|
if out.Title != in.Title {
|
|
t.Errorf("Title = %q, want %q", out.Title, in.Title)
|
|
}
|
|
if len(out.ACL.Allow) != 1 || out.ACL.Allow[0] != in.ACL.Allow[0] {
|
|
t.Errorf("ACL.Allow = %v, want %v", out.ACL.Allow, in.ACL.Allow)
|
|
}
|
|
if len(out.Admins) != 1 || out.Admins[0] != "alice@varasys.io" {
|
|
t.Errorf("Admins = %v, want [alice@varasys.io]", out.Admins)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileAtomicNoTempLeftBehind(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := WriteFile(root, ZddcFile{Title: "a"}); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir: %v", err)
|
|
}
|
|
for _, e := range entries {
|
|
if strings.HasSuffix(e.Name(), ".tmp") {
|
|
t.Errorf("temp file left behind: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriteFileInvalidatesCache(t *testing.T) {
|
|
root := t.TempDir()
|
|
sub := filepath.Join(root, "project")
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
// Prime the cache with an empty chain.
|
|
if _, err := EffectivePolicy(root, sub); err != nil {
|
|
t.Fatalf("prime cache: %v", err)
|
|
}
|
|
|
|
if err := WriteFile(sub, ZddcFile{
|
|
ACL: ACLRules{Allow: []string{"alice@example.com"}},
|
|
}); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
// After write the cache must reflect the new content.
|
|
chain, err := EffectivePolicy(root, sub)
|
|
if err != nil {
|
|
t.Fatalf("EffectivePolicy: %v", err)
|
|
}
|
|
if !chain.HasAnyFile {
|
|
t.Fatal("HasAnyFile = false; cache not invalidated")
|
|
}
|
|
leaf := chain.Levels[len(chain.Levels)-1]
|
|
if len(leaf.ACL.Allow) != 1 || leaf.ACL.Allow[0] != "alice@example.com" {
|
|
t.Errorf("leaf allow = %v, want [alice@example.com]", leaf.ACL.Allow)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileOverwritePreservesOriginalOnFailure(t *testing.T) {
|
|
// We can't easily simulate a rename failure portably, but we can at
|
|
// least confirm that the happy-path overwrite produces the new
|
|
// content (so the rename worked) and that the previous content is
|
|
// gone (no merge or append).
|
|
root := t.TempDir()
|
|
if err := WriteFile(root, ZddcFile{Title: "first"}); err != nil {
|
|
t.Fatalf("first write: %v", err)
|
|
}
|
|
if err := WriteFile(root, ZddcFile{Title: "second"}); err != nil {
|
|
t.Fatalf("second write: %v", err)
|
|
}
|
|
out, err := ParseFile(filepath.Join(root, ".zddc"))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if out.Title != "second" {
|
|
t.Errorf("Title = %q, want %q", out.Title, "second")
|
|
}
|
|
}
|
|
|
|
func TestDeleteFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := WriteFile(root, ZddcFile{Title: "a"}); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, ".zddc")); err != nil {
|
|
t.Fatalf("file should exist before delete: %v", err)
|
|
}
|
|
|
|
// Prime the cache so we can verify invalidation post-delete.
|
|
if _, err := EffectivePolicy(root, root); err != nil {
|
|
t.Fatalf("prime: %v", err)
|
|
}
|
|
|
|
if err := DeleteFile(root); err != nil {
|
|
t.Fatalf("DeleteFile: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, ".zddc")); !os.IsNotExist(err) {
|
|
t.Errorf("file should be gone after delete: err=%v", err)
|
|
}
|
|
chain, err := EffectivePolicy(root, root)
|
|
if err != nil {
|
|
t.Fatalf("EffectivePolicy: %v", err)
|
|
}
|
|
if chain.HasAnyFile {
|
|
t.Error("HasAnyFile should be false after delete; cache not invalidated")
|
|
}
|
|
}
|
|
|
|
func TestDeleteFileMissing(t *testing.T) {
|
|
root := t.TempDir()
|
|
// No .zddc has been written; delete must be a no-op.
|
|
if err := DeleteFile(root); err != nil {
|
|
t.Errorf("DeleteFile on missing file = %v, want nil", err)
|
|
}
|
|
}
|