ZDDC/zddc/internal/handler/zddchandler_test.go
ZDDC cb46c2ef8c feat(zddc-server): user profile page replaces /.admin/
Replaces the super-admin-only /.admin/ surface with a public-by-default
/.profile/ page that layers admin tools server-side based on the
caller's effective access:

- Universal (everyone, anonymous included): identity card, effective
  access summary, theme picker, localStorage utilities (export / import
  / clear, landing-presets viewer).
- Subtree admins additionally see: editable .zddc files list (linking
  to the existing form-based editor) and a "Create new project folder"
  form.
- Super-admins additionally see: server config, log viewer, whoami
  headers (the old /.admin/ JSON endpoints, repointed under /.profile/).

Project creation is gated on CanEditZddc(newDir) — the same strict-
ancestor rule that already governs .zddc writes — so no new authority
concept is introduced. ValidateProjectName mirrors the existing
reserved-prefix policy (no leading '.' or '_', no path separators).

/.admin/* is hard-cut: no redirect shim. Old URLs fall through to the
existing dot-prefix guard and 404. Custom CSS file rename: prefer
<root>/.profile.css, fall back to legacy <root>/.admin.css.

Per-resource 404 leakage gates preserved on whoami / config / logs /
zddc / projects so non-admin callers cannot detect the existence of
admin-only sub-resources.

Tree-wide gofmt -w applied as a side-effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:32:02 -05:00

362 lines
14 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// zddcTestSetup writes a tree of .zddc files and returns the root and a
// helper that builds requests with an injected user email. files keys
// are paths relative to root; the empty string is the root itself. Each
// path is created as a directory; if the value is non-empty it is
// written as that directory's .zddc.
func zddcTestSetup(t *testing.T, files map[string]string) (cfg config.Config, do func(method, target, email, body string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
for rel, body := range files {
dir := filepath.Join(root, rel)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
zddc.InvalidateCache(dir)
if body == "" {
continue
}
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
}
cfg = config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
do = func(method, target, email, body string) *httptest.ResponseRecorder {
var rdr *bytes.Reader
if body != "" {
rdr = bytes.NewReader([]byte(body))
}
var req *http.Request
if rdr != nil {
req = httptest.NewRequest(method, target, rdr)
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
return rec
}
return cfg, do
}
func TestServeZddcAuthGate(t *testing.T) {
// root admin = root@example.com; subtree admin alice@example.com on /projects.
cfg, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/x": "",
})
cases := []struct {
name string
method string
target string
email string
wantStatus int
}{
{"anon GET root", http.MethodGet, "/.profile/zddc?path=/", "", http.StatusNotFound},
{"non-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "mallory@example.com", http.StatusNotFound},
{"super-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
{"subtree-admin GET root (read-only)", http.MethodGet, "/.profile/zddc?path=/", "alice@example.com", http.StatusOK},
{"subtree-admin GET own grant file (read-only)", http.MethodGet, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusOK},
{"subtree-admin GET deeper", http.MethodGet, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"subtree-admin POST own grant file (forbidden)", http.MethodPost, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusForbidden},
{"subtree-admin POST deeper (allowed)", http.MethodPost, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"super-admin POST root", http.MethodPost, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
{"non-admin POST anywhere", http.MethodPost, "/.profile/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
{"DELETE root rejected", http.MethodDelete, "/.profile/zddc?path=/", "root@example.com", http.StatusBadRequest},
{"super-admin DELETE leaf", http.MethodDelete, "/.profile/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
}
_ = cfg
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := ""
if tc.method == http.MethodPost {
if tc.target == "/.profile/zddc?path=/" {
// Root POST: writer must remain in admins list.
body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}`
} else {
body = `{"title":"x","acl":{"allow":["*@example.com"],"deny":[]},"admins":[]}`
}
}
rec := do(tc.method, tc.target, tc.email, body)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d; body=%s", rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
func TestServeZddcGetReturnsChain(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\nacl:\n allow: [\"*@example.com\"]\n",
"projects": "title: All Projects\n",
"projects/sub": "title: Substation\n",
})
rec := do(http.MethodGet, "/.profile/zddc?path=/projects/sub", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Path != "/projects/sub" {
t.Errorf("path = %q, want /projects/sub", resp.Path)
}
if !resp.CanEdit {
t.Errorf("CanEdit = false; root admin should edit anywhere")
}
if !resp.Exists {
t.Errorf("Exists = false but file was written")
}
if len(resp.EffectiveChain) != 3 {
t.Fatalf("chain length = %d, want 3 (root, projects, projects/sub)", len(resp.EffectiveChain))
}
if resp.EffectiveChain[0].Dir != "/" {
t.Errorf("chain[0].Dir = %q, want /", resp.EffectiveChain[0].Dir)
}
if resp.EffectiveChain[1].Dir != "/projects" {
t.Errorf("chain[1].Dir = %q, want /projects", resp.EffectiveChain[1].Dir)
}
if resp.EffectiveChain[2].Title != "Substation" {
t.Errorf("chain[2].Title = %q, want Substation", resp.EffectiveChain[2].Title)
}
}
func TestServeZddcPostValidatesGlob(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"x","acl":{"allow":["alice@@bad","good@example.com"],"deny":[]},"admins":[]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
var we writeError
if err := json.Unmarshal(rec.Body.Bytes(), &we); err != nil {
t.Fatalf("decode err body: %v", err)
}
if len(we.Errors) == 0 || we.Errors[0].Field != "acl.allow[0]" {
t.Errorf("expected acl.allow[0] error, got %+v", we.Errors)
}
}
func TestServeZddcRootSelfDemotionRejected(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n - bob@example.com\n",
})
// root tries to remove themselves, leaving only bob.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["bob@example.com"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (self-demotion rejected); body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcRootKeepingSelfAccepted(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
})
// root adds bob alongside themselves — fine.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com","bob@example.com"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcWriteRoundTrip(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "",
})
body := `{"title":"Engineering","acl":{"allow":["*@varasys.io"],"deny":[]},"admins":["alice@varasys.io"]}`
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("write status = %d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("get status = %d body=%s", rec.Code, rec.Body.String())
}
var resp zddcGetResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.File.Title != "Engineering" {
t.Errorf("title round-trip = %q, want Engineering", resp.File.Title)
}
if len(resp.File.Admins) != 1 || resp.File.Admins[0] != "alice@varasys.io" {
t.Errorf("admins round-trip = %v, want [alice@varasys.io]", resp.File.Admins)
}
}
func TestServeZddcTreeFiltersByVisibility(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"alpha": "admins:\n - alice@example.com\n",
"alpha/x": "title: alpha-x\n",
"beta": "admins:\n - bob@example.com\n",
})
// alice sees alpha (her grant) and alpha/x (descendant), but not beta.
rec := do(http.MethodGet, "/.profile/zddc/tree", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var entries []treeEntry
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
t.Fatalf("decode: %v", err)
}
seen := map[string]bool{}
for _, e := range entries {
seen[e.Path] = true
}
if !seen["/alpha"] || !seen["/alpha/x"] {
t.Errorf("alice should see /alpha and /alpha/x; got %v", seen)
}
if seen["/beta"] {
t.Errorf("alice should NOT see /beta; got %v", seen)
}
}
func TestServeZddcEditorRenders(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "title: Engineering\n",
})
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Engineering") {
t.Errorf("editor should pre-fill title; body did not contain 'Engineering'")
}
if !strings.Contains(body, "/.profile/zddc?path=") {
t.Errorf("editor should reference API URL; body lacks /.profile/zddc?path=")
}
if !strings.Contains(body, "Subtree admins of /projects") {
t.Errorf("editor should label admins section as subtree (not bootstrap) for non-root file")
}
}
func TestServeZddcEditorReadOnlyForNonEditor(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
})
// alice viewing her own grant file: read-only.
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Read-only") {
t.Errorf("editor should show Read-only banner for non-editor; body lacks it")
}
}
func TestServeZddcRejectsReservedPathSegments(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
})
for _, p := range []string{"/.foo", "/_bar", "/projects/.evil"} {
rec := do(http.MethodGet, "/.profile/zddc?path="+p, "root@example.com", "")
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q expected 404, got %d", p, rec.Code)
}
}
}
func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
// Confirm that putting /.profile/zddc/* under the broader gate did not
// regress the super-admin gate on /.profile/whoami etc.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("non-admin /.profile/whoami got %d, want 404", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeProfile(cfg, nil, rec, req)
if rec.Code != http.StatusOK {
t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestServeZddcAssetsCustomCSS(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
if err := os.WriteFile(filepath.Join(root, ".admin.css"), []byte("body { color: red; }"), 0o644); err != nil {
t.Fatalf("write .admin.css: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Errorf("Content-Type = %q, want text/css...", ct)
}
if !strings.Contains(rec.Body.String(), "color: red") {
t.Errorf("body does not contain custom CSS")
}
}
func TestServeZddcAssetsAbsentReturns404(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
}