ZDDC/zddc/internal/handler/projectshandler_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

117 lines
3.6 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// TestServeProjectListFiltersHiddenAndScaffolding asserts the project list
// excludes both '.'-prefixed entries (the long-standing rule, e.g. .devshell)
// AND '_'-prefixed entries (operator scaffolding like install.zip's
// _template/ that's reachable by direct URL but should not clutter the
// project picker).
func TestServeProjectListFiltersHiddenAndScaffolding(t *testing.T) {
root := t.TempDir()
for _, name := range []string{
"Project-A",
"Project-B",
".devshell", // dot-prefixed dir — must be excluded
"_template", // underscore scaffolding — must be excluded
"_archive",
} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
}
// A loose .zddc that allows everyone, so ACL doesn't interfere with
// what we're actually testing (the prefix filter).
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProjectList(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
var got []map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
want := map[string]bool{"Project-A": true, "Project-B": true}
if len(got) != len(want) {
t.Fatalf("got %d projects (%+v), want %d (%v)", len(got), got, len(want), want)
}
for _, p := range got {
if !want[p["name"]] {
t.Errorf("unexpected project in list: %q", p["name"])
}
}
}
// TestServeProjectListIncludesTitleFromPerProjectZddc verifies a project's own
// .zddc `title:` field surfaces in the JSON response; projects without it (or
// without any .zddc) come back with an empty/absent title.
func TestServeProjectListIncludesTitleFromPerProjectZddc(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"176109", "197072"} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
}
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "176109", ".zddc"),
[]byte("title: \"Greenfield Substation\"\n"), 0o644); err != nil {
t.Fatalf("write project .zddc: %v", err)
}
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProjectList(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
var got []ProjectInfo
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
titles := map[string]string{}
for _, p := range got {
titles[p.Name] = p.Title
}
if titles["176109"] != "Greenfield Substation" {
t.Errorf("176109 title = %q, want %q", titles["176109"], "Greenfield Substation")
}
if titles["197072"] != "" {
t.Errorf("197072 title = %q, want empty", titles["197072"])
}
}