ZDDC/zddc/internal/zddc/writer_test.go
ZDDC e44ccc3500 feat(zddc-server): delegated subtree admins + built-in .zddc editor
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>
2026-04-29 12:52:06 -05:00

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)
}
}