ZDDC/zddc/internal/handler/profilehandler_test.go
ZDDC d9256050d2 feat(profile): render /.profile via the tables engine (access + create + diagnostics)
Retire the bespoke profile page. /.profile now renders through the shared
tables engine (header chrome incl. the profile menu) from a server-injected
context: the caller's "Effective access" — projects + admin subtrees — as
clickable rows (rowNav opens each), identity in the description, and an
apiActions "+ New project" (name → POST /.profile/projects, gated on
can_create_project; roles are added afterward by editing the project's
.zddc, which is now standing-editable). Super-admin diagnostics
(config/logs/whoami/effective-policy) stay discoverable as rows linking to
their unchanged endpoints — gated on IsSuperAdmin so a non-admin's context
never even names them.

Dropped as redundant/niche: the in-page theme picker (the header has the
theme button), the localStorage inspector, and the "editable .zddc" links
(those files are now standing-editable in browse).

Extends the generic apiActions layer (tables/js/api-actions.js) with
`fixed` constant fields (e.g. parent="/"), `required` field validation, and
`rowNav` clickable rows (capture-phase, so it beats the editor's per-cell
handlers). Rewrote TestServeProfileHTMLLayered to the new model (per-role
context correctness: no admin leak; super-admin diagnostics present) and
dropped the now-dead stripTemplates helper. Validated in a containerized
browser; full Go suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:07:56 -05:00

971 lines
38 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// profileTestRoot creates a temp dir, writes a .zddc with the given admins
// list, and returns a Config pointing at it.
func profileTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) {
t.Helper()
root := t.TempDir()
if len(admins) > 0 {
var b strings.Builder
b.WriteString("admins:\n")
for _, a := range admins {
b.WriteString(" - \"")
b.WriteString(a)
b.WriteString("\"\n")
}
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
}
return config.Config{
Root: root,
Addr: ":8443",
EmailHeader: "X-Auth-Request-Email",
}, NewLogRing(50)
}
// requestAsAdmin builds a test request whose context carries email
// AND Elevated=true — the wire shape ACLMiddleware would inject for
// a bearer-token caller or a browser session with the elevation
// cookie set. Name is the convention: every admin-action test should
// reach for THIS helper, so the call site visibly opts into admin
// authority. Tests that need to exercise the un-elevated path use
// requestAsUserMaybeElevated(method, path, email, false) explicitly —
// see the un-elevated negative tests in admin_test.go for that shape.
func requestAsAdmin(method, path, email string) *http.Request {
return requestAsUserMaybeElevated(method, path, email, true)
}
// requestAsUserMaybeElevated is the explicit form. Tests for the
// "un-elevated admin should fail closed" gate pass elevated=false.
func requestAsUserMaybeElevated(method, path, email string, elevated bool) *http.Request {
r := httptest.NewRequest(method, path, nil)
if email != "" {
r.Header.Set("X-Auth-Request-Email", email)
ctx := context.WithValue(r.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, elevated)
r = r.WithContext(ctx)
}
return r
}
// TestServeProfileGateMatrix checks the authorization decisions for every
// sub-route. The page itself (/.profile/) is reachable to anyone (anonymous
// included); admin-only sub-resources stay 404 for non-eligible callers,
// preserving the existence-leakage policy on a per-resource basis.
func TestServeProfileGateMatrix(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
cases := []struct {
name string
path string
email string
wantStatus int
}{
// /.profile/ itself — public landing for everyone.
{"anonymous /.profile/", "/.profile/", "", http.StatusOK},
{"non-admin /.profile/", "/.profile/", "bob@example.com", http.StatusOK},
{"admin /.profile/", "/.profile/", "alice@example.com", http.StatusOK},
// /.profile/access — JSON, also public.
{"anonymous /.profile/access", "/.profile/access", "", http.StatusOK},
{"admin /.profile/access", "/.profile/access", "alice@example.com", http.StatusOK},
// Admin-only sub-resources — 404 for non-eligible callers.
{"anonymous /.profile/whoami", "/.profile/whoami", "", http.StatusNotFound},
{"anonymous /.profile/config", "/.profile/config", "", http.StatusNotFound},
{"anonymous /.profile/logs", "/.profile/logs", "", http.StatusNotFound},
{"non-admin /.profile/whoami", "/.profile/whoami", "bob@example.com", http.StatusNotFound},
{"non-admin /.profile/config", "/.profile/config", "bob@example.com", http.StatusNotFound},
{"non-admin /.profile/logs", "/.profile/logs", "bob@example.com", http.StatusNotFound},
{"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK},
{"admin /.profile/config", "/.profile/config", "alice@example.com", http.StatusOK},
{"admin /.profile/logs", "/.profile/logs", "alice@example.com", http.StatusOK},
// effective-policy is admin-only too. With no params an admin
// gets 400 (bad request), confirming the gate cleared. Same
// 404 for non-admins as the other admin-only routes.
{"anonymous /.profile/effective-policy", "/.profile/effective-policy", "", http.StatusNotFound},
{"non-admin /.profile/effective-policy", "/.profile/effective-policy", "bob@example.com", http.StatusNotFound},
{"admin /.profile/effective-policy without params", "/.profile/effective-policy", "alice@example.com", http.StatusBadRequest},
// /.profile/reindex is admin-only too. GET (the matrix uses GET) is
// rejected with 405 once the admin gate clears, confirming the gate
// behaves the same way as the others. Real triggering is POST and is
// covered separately by TestServeProfileReindexPOST.
{"anonymous /.profile/reindex", "/.profile/reindex", "", http.StatusNotFound},
{"non-admin /.profile/reindex", "/.profile/reindex", "bob@example.com", http.StatusNotFound},
{"admin GET /.profile/reindex (POST-only)", "/.profile/reindex", "alice@example.com", http.StatusMethodNotAllowed},
// Unknown sub-route still 404.
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, tc.path, tc.email))
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
func TestServeProfileWhoamiPayload(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
r := requestAsAdmin(http.MethodGet, "/.profile/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there")
ServeProfile(cfg, ring, nil, rec, r)
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
if got["configured_email_header"] != "X-Auth-Request-Email" {
t.Errorf("configured_email_header = %v", got["configured_email_header"])
}
if got["observed_email"] != "alice@example.com" {
t.Errorf("observed_email = %v", got["observed_email"])
}
if got["resolved_email"] != "alice@example.com" {
t.Errorf("resolved_email = %v", got["resolved_email"])
}
headers, _ := got["headers"].(map[string]any)
if _, ok := headers["X-Auth-Request-Email"]; !ok {
t.Errorf("headers map missing X-Auth-Request-Email: %+v", headers)
}
if _, ok := headers["X-Other-Header"]; !ok {
t.Errorf("headers map missing X-Other-Header: %+v", headers)
}
}
func TestServeProfileConfigPayload(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
cfg.LogLevel = "info"
cfg.IndexPath = ".archive"
cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/config", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, want := range []string{"root", "addr", "email_header", "log_level", "cors_origins"} {
if _, ok := got[want]; !ok {
t.Errorf("config payload missing key %q: %+v", want, got)
}
}
if got["email_header"] != "X-Auth-Request-Email" {
t.Errorf("email_header = %v", got["email_header"])
}
}
func TestServeProfileLogsPayload(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rh := NewRingHandler(ring, slog.LevelDebug)
logger := slog.New(rh)
logger.Info("first")
logger.Warn("second", "code", 42)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/logs", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
if len(got) != 2 {
t.Fatalf("entries = %d, want 2", len(got))
}
if got[0]["message"] != "first" || got[1]["message"] != "second" {
t.Errorf("ordering wrong: %v / %v", got[0]["message"], got[1]["message"])
}
}
func TestServeProfileLogsLevelFilter(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rh := NewRingHandler(ring, slog.LevelDebug)
logger := slog.New(rh)
logger.Debug("d")
logger.Info("i")
logger.Warn("w")
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestAsAdmin(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
var got []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got) != 1 || got[0]["message"] != "w" {
t.Errorf("level=warn filter failed: %+v", got)
}
}
// TestServeProfileHTMLLayered pins the page-render contract after the
// lazy-load refactor:
//
// - The shell is the same byte-stream for every caller modulo the
// identity card and the super-admin diagnostics scaffold (gated by the
// cheap IsSuperAdmin check on the root .zddc).
// - Subtree-admin scaffolds (Editable .zddc files / Create new project)
// live ONLY inside <template id="tmpl-subtree-admin">. Pure non-admins
// receive the inert template but no live form, button handler, or
// event-bound markup.
// - Subtree-admin discovery moved to /.profile/access; the server-side
// render no longer needs to walk the .zddc tree.
//
// Subtree-admin and super-admin behaviour beyond identity + diagnostics is
// covered by TestServeProfileAccessJSON since it now flows through the
// JSON endpoint, not the shell.
func TestServeProfileHTMLLayered(t *testing.T) {
root := t.TempDir()
zf := "admins:\n - alice@example.com\n"
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(zf), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n - bob@example.com\n"), 0o644); err != nil {
t.Fatalf("write subtree .zddc: %v", err)
}
zddc.InvalidateCache(root)
zddc.InvalidateCache(filepath.Join(root, "projects"))
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
ring := NewLogRing(50)
render := func(email string) string {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("email=%q Content-Type = %q, want text/html", email, ct)
}
return rec.Body.String()
}
// The page now renders through the shared tables engine with a server-
// injected #table-context (no bespoke scaffolds). The per-role contract:
// the context must never NAME a capability the caller lacks — super-admin
// diagnostics (config/logs/whoami) appear only for a super-admin, so a
// non-admin's bytes can't even reference them.
diag := ProfilePathPrefix + "/config"
// Anonymous: "Not signed in" identity, no diagnostics.
anon := render("")
if !strings.Contains(anon, "Not signed in") {
t.Errorf("anonymous body missing 'Not signed in'")
}
if !strings.Contains(anon, `id="table-context"`) {
t.Errorf("profile page not rendered via the tables engine")
}
if strings.Contains(anon, diag) {
t.Errorf("anonymous body leaks super-admin diagnostics (%q)", diag)
}
// Non-admin (carol): email shown, no diagnostics.
nonAdmin := render("carol@example.com")
if !strings.Contains(nonAdmin, "carol@example.com") {
t.Errorf("non-admin body missing email")
}
if strings.Contains(nonAdmin, diag) {
t.Errorf("non-admin body leaks super-admin diagnostics")
}
// Subtree-admin (bob): administers projects/, but is NOT a root super-
// admin — still no diagnostics.
subtree := render("bob@example.com")
if strings.Contains(subtree, diag) {
t.Errorf("subtree-admin body leaks super-admin diagnostics")
}
// Super-admin (alice): diagnostics are discoverable as rows linking to
// the (unchanged) endpoints.
super := render("alice@example.com")
for _, link := range []string{
ProfilePathPrefix + "/config",
ProfilePathPrefix + "/logs",
ProfilePathPrefix + "/whoami",
} {
if !strings.Contains(super, link) {
t.Errorf("super-admin profile missing diagnostic link %q", link)
}
}
}
func TestServeProfileAccessJSON(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var v AccessView
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode: %v", err)
}
if v.Email != "alice@example.com" || !v.IsSuperAdmin {
t.Errorf("expected super-admin alice; got %+v", v)
}
if v.EmailHeader != "X-Auth-Request-Email" {
t.Errorf("EmailHeader = %q", v.EmailHeader)
}
}
// Subtree-admin discovery used to live in the HTML render; now it flows
// through /.profile/access. Verify the JSON endpoint exposes what the
// IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
// for both the read-only list AND the parent-selector options, and
// HasAnyAdminScope so the IIFE knows whether to clone the <template>.
// Pure non-admins get an empty access view and no scaffold.
func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n - bob@example.com\n"), 0o644); err != nil {
t.Fatalf("write subtree .zddc: %v", err)
}
zddc.InvalidateCache(root)
zddc.InvalidateCache(filepath.Join(root, "projects"))
zddc.InvalidateScanCache()
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
ring := NewLogRing(50)
fetchAccess := func(email string) AccessView {
t.Helper()
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
var v AccessView
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode email=%q: %v", email, err)
}
return v
}
// Pure non-admin: no admin scope, IIFE skips the template hydration.
carol := fetchAccess("carol@example.com")
if carol.HasAnyAdminScope {
t.Errorf("carol HasAnyAdminScope = true, want false")
}
if len(carol.AdminSubtrees) != 0 {
t.Errorf("carol AdminSubtrees = %v, want empty", carol.AdminSubtrees)
}
// Subtree-admin: AdminSubtrees lists projects/ so the create-project
// parent dropdown can offer it; HasAnyAdminScope triggers template
// hydration. Subtree admins own their .zddc (strict-ancestor retired),
// so bob's projects/ entry is plainly listed and the Editable-files
// list will render it inline.
bob := fetchAccess("bob@example.com")
if bob.IsSuperAdmin {
t.Errorf("bob IsSuperAdmin = true, want false")
}
if !bob.HasAnyAdminScope {
t.Errorf("bob HasAnyAdminScope = false, want true")
}
if len(bob.AdminSubtrees) == 0 {
t.Fatalf("bob AdminSubtrees empty; want projects/")
}
gotProjects := false
for _, s := range bob.AdminSubtrees {
if strings.HasSuffix(s.Path, "/projects") {
gotProjects = true
}
}
if !gotProjects {
t.Errorf("bob AdminSubtrees missing projects/: %+v", bob.AdminSubtrees)
}
// Super-admin: AdminSubtrees enumerates every .zddc directory.
alice := fetchAccess("alice@example.com")
if !alice.IsSuperAdmin || !alice.HasAnyAdminScope {
t.Errorf("alice IsSuperAdmin=%v HasAnyAdminScope=%v, want both true", alice.IsSuperAdmin, alice.HasAnyAdminScope)
}
if len(alice.AdminSubtrees) < 2 {
t.Errorf("alice should see at least the root + projects/ subtrees; got %+v", alice.AdminSubtrees)
}
}
// TestServeProfileAccessPathScoped — /.profile/access?path=<url> answers
// "what can the caller do at this URL" alongside the global view. Three
// flavors cover the cases the toast/menu gating cares about:
//
// - non-admin caller with explicit ACL grant: PathVerbs reflects the
// grant; PathIsAdmin=false; PathCanElevateGrant empty (elevation
// wouldn't change anything for a non-admin).
// - un-elevated admin: PathVerbs reflects the explicit grant (no
// admin bypass yet); PathIsAdmin=false; PathCanElevateGrant carries
// the full "rwcda" elevation would unlock.
// - elevated admin: PathVerbs="rwcda" (admin bypass active);
// PathIsAdmin=true; PathCanElevateGrant empty (nothing to upgrade).
func TestServeProfileAccessPathScoped(t *testing.T) {
root := t.TempDir()
// Root admins list — sudo authority for admin@example.com (when
// elevated). Permissions grant alice rw at the project level.
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(`admins:
- admin@example.com
acl:
permissions:
"alice@example.com": rw
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
zddc.InvalidateScanCache()
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
ring := NewLogRing(50)
fetch := func(email string, elevated bool) AccessView {
t.Helper()
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestAsUserMaybeElevated(http.MethodGet, "/.profile/access?path=/Proj/", email, elevated))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q elevated=%v status=%d body=%s", email, elevated, rec.Code, rec.Body.String())
}
var v AccessView
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode email=%q: %v", email, err)
}
return v
}
// Non-admin caller with explicit grant: verbs reflect the ACL,
// no admin status, no elevation offer.
alice := fetch("alice@example.com", false)
if alice.PathVerbs != "rw" {
t.Errorf("alice PathVerbs = %q, want rw", alice.PathVerbs)
}
// A path-scoped query returns ONLY the access for this location — the
// global summary (projects + admin-subtree walks) is omitted.
if len(alice.Projects) != 0 || len(alice.AdminSubtrees) != 0 || alice.CanCreateProject {
t.Errorf("path-scoped response leaked global fields: Projects=%d AdminSubtrees=%d CanCreateProject=%v",
len(alice.Projects), len(alice.AdminSubtrees), alice.CanCreateProject)
}
if alice.PathIsAdmin {
t.Errorf("alice PathIsAdmin = true, want false")
}
if alice.PathCanElevateGrant != "" {
t.Errorf("alice PathCanElevateGrant = %q, want empty (no admin grant on chain)", alice.PathCanElevateGrant)
}
// Un-elevated admin: the WORM/destructive bypass is not active, but
// config-edit is a STANDING permission — being in the admins: cascade
// grants `a` (edit .zddc/.zddc.zip/roles) without elevating. So the
// explicit verbs are exactly "a" even though admin@ has no acl
// permissions entry. PathCanElevateGrant tells the client "elevation
// would unlock the rest (rwcda)".
adminUn := fetch("admin@example.com", false)
if adminUn.PathVerbs != "a" {
t.Errorf("un-elevated admin PathVerbs = %q, want \"a\" (standing config-edit)", adminUn.PathVerbs)
}
if adminUn.PathIsAdmin {
t.Errorf("un-elevated admin PathIsAdmin = true, want false")
}
if adminUn.PathCanElevateGrant != "rwcda" {
t.Errorf("un-elevated admin PathCanElevateGrant = %q, want rwcda", adminUn.PathCanElevateGrant)
}
// Elevated admin: full bypass — verbs rwcda, PathIsAdmin true,
// no elevation offer (already elevated).
adminEl := fetch("admin@example.com", true)
if adminEl.PathVerbs != "rwcda" {
t.Errorf("elevated admin PathVerbs = %q, want rwcda", adminEl.PathVerbs)
}
if !adminEl.PathIsAdmin {
t.Errorf("elevated admin PathIsAdmin = false, want true")
}
if adminEl.PathCanElevateGrant != "" {
t.Errorf("elevated admin PathCanElevateGrant = %q, want empty (already elevated)", adminEl.PathCanElevateGrant)
}
}
// TestServeProfileAccessNoPathQuery — without ?path=, the global view
// works unchanged: path-scoped fields are absent, every existing
// global field is populated.
func TestServeProfileAccessNoPathQuery(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
var v AccessView
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode: %v", err)
}
if v.PathVerbs != "" || v.PathIsAdmin || v.PathCanElevateGrant != "" {
t.Errorf("global view should not include path-scoped fields; got PathVerbs=%q PathIsAdmin=%v PathCanElevateGrant=%q",
v.PathVerbs, v.PathIsAdmin, v.PathCanElevateGrant)
}
}
// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a
// (path, email) tuple and gets back the resolved chain plus the decision.
// The fixture mirrors the worked-example layout from zddc/README.md (a
// closed project where alice is allow-listed but bob is not, even though
// /Archive/ would let *@mycompany.com in).
func TestServeProfileEffectivePolicy(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"super@admin.com"})
if err := os.MkdirAll(filepath.Join(cfg.Root, "Closed-Project"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"),
[]byte("acl:\n permissions:\n alice@mycompany.com: rwcd\n"), 0o644); err != nil {
t.Fatalf("write child .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
// Trace alice (allowed at the leaf).
rec := httptest.NewRecorder()
r := requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, nil, rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Path string `json:"path"`
Email string `json:"email"`
Decision bool `json:"decision"`
Chain struct {
HasAnyFile bool `json:"has_any_file"`
Levels []struct {
Index int `json:"index"`
Exists bool `json:"exists"`
MatchesEmail bool `json:"matches_email"`
DecisionAtLevel string `json:"decision_at_level"`
} `json:"levels"`
} `json:"chain"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Path != "/Closed-Project/" || resp.Email != "alice@mycompany.com" {
t.Errorf("path/email round-trip mismatch: %+v", resp)
}
if !resp.Decision {
t.Errorf("decision = false, want true (alice is allow-listed at /Closed-Project/)")
}
if !resp.Chain.HasAnyFile {
t.Error("HasAnyFile = false, want true (.zddc files exist)")
}
if len(resp.Chain.Levels) != 2 {
t.Fatalf("levels count = %d, want 2 (root + Closed-Project/)", len(resp.Chain.Levels))
}
// Leaf level should have matched alice with allow.
leaf := resp.Chain.Levels[1]
if !leaf.MatchesEmail || leaf.DecisionAtLevel != "allow" {
t.Errorf("leaf decision = %q (matches=%v), want allow (matches=true)", leaf.DecisionAtLevel, leaf.MatchesEmail)
}
// Trace bob (not allow-listed; root has no broad allow either).
rec2 := httptest.NewRecorder()
r2 := requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, nil, rec2, r2)
if rec2.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String())
}
var resp2 struct {
Decision bool `json:"decision"`
}
if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp2.Decision {
t.Error("decision = true for bob, want false (no .zddc match anywhere; HasAnyFile=true → default-deny)")
}
}
// TestServeProfileEffectivePolicy_InheritFence: a child .zddc with
// inherit:false fences ancestor grants. The tracer surfaces both the
// per-level inherit flag and the chain-level visible_start so an
// operator can see why ancestor grants don't apply at the leaf.
func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"super@admin.com"})
// Vendor-folder pattern: root grants everyone-at-mycompany rwcd;
// the vendor folder fences and only allows the vendor. Preserve
// the admins: list so the test admin can hit the tracer.
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
[]byte("admins:\n - super@admin.com\nacl:\n permissions:\n \"*@mycompany.com\": rwcd\n"),
0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(cfg.Root, "Vendor"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.Root, "Vendor", ".zddc"),
[]byte("acl:\n inherit: false\n permissions:\n \"*@vendor.com\": rwcd\n"),
0o644); err != nil {
t.Fatalf("write vendor .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
type respShape struct {
Decision bool `json:"decision"`
Chain struct {
VisibleStart int `json:"visible_start"`
Levels []struct {
Index int `json:"index"`
Inherit *bool `json:"inherit,omitempty"`
} `json:"levels"`
} `json:"chain"`
}
// Trace a my-company user — fenced out at the leaf, despite root grant.
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestAsAdmin(http.MethodGet,
"/.profile/effective-policy?path=/Vendor/&email=alice@mycompany.com",
"super@admin.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var got respShape
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Decision {
t.Errorf("alice should be fenced out; decision = true")
}
if got.Chain.VisibleStart != 1 {
t.Errorf("VisibleStart = %d, want 1 (fence at /Vendor/)", got.Chain.VisibleStart)
}
if len(got.Chain.Levels) != 2 {
t.Fatalf("expected 2 levels; got %d", len(got.Chain.Levels))
}
leaf := got.Chain.Levels[1]
if leaf.Inherit == nil || *leaf.Inherit != false {
t.Errorf("leaf.Inherit should be explicit false; got %v", leaf.Inherit)
}
root := got.Chain.Levels[0]
if root.Inherit != nil {
t.Errorf("root.Inherit should be unset; got %v", root.Inherit)
}
}
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// .zddc exists but has no admins list — page is still reachable,
// but the admin/super-admin sections are absent.
cfg, ring := profileTestRoot(t, nil)
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/", "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, "Server config") {
t.Errorf("alice should not see super-admin section when no admins configured")
}
// Per-resource gates remain.
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/whoami", "alice@example.com"))
if rec.Code != http.StatusNotFound {
t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code)
}
}
// TestServeProfileProjectsCreate covers the happy path and the most
// common rejection modes for POST /.profile/projects.
func TestServeProfileProjectsCreate(t *testing.T) {
root := t.TempDir()
zf := "admins:\n - root@example.com\n"
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(zf), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
ring := NewLogRing(50)
post := func(email, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if email != "" {
ctx := context.WithValue(req.Context(), EmailKey, email)
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, req)
return rec
}
// Happy path: super-admin creates /alpha with no .zddc body.
// Post-refactor: the .zddc IS auto-written with the creator in
// admins: so they own the new project from birth.
rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil {
t.Errorf("alpha dir not created on disk: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err != nil {
t.Errorf(".zddc should be auto-written with creator as admin: %v", err)
} else if zf, perr := zddc.ParseFile(filepath.Join(root, "alpha", ".zddc")); perr == nil {
if len(zf.Admins) != 1 || zf.Admins[0] != "root@example.com" {
t.Errorf("alpha .zddc Admins=%v, want [root@example.com]", zf.Admins)
}
}
// Body with a title also writes a .zddc.
rec = post("root@example.com", `{"parent":"/", "name":"beta", "title":"Beta site"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create-with-title status=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "beta", ".zddc")); err != nil {
t.Errorf(".zddc should be written when title supplied: %v", err)
}
// Conflict on existing dir.
rec = post("root@example.com", `{"parent":"/", "name":"alpha"}`)
if rec.Code != http.StatusConflict {
t.Errorf("duplicate create status=%d, want 409 (body=%s)", rec.Code, rec.Body.String())
}
// Bad name.
rec = post("root@example.com", `{"parent":"/", "name":".hidden"}`)
if rec.Code != http.StatusBadRequest {
t.Errorf("bad name status=%d, want 400 (body=%s)", rec.Code, rec.Body.String())
}
rec = post("root@example.com", `{"parent":"/", "name":"a/b"}`)
if rec.Code != http.StatusBadRequest {
t.Errorf("path-separator name status=%d, want 400", rec.Code)
}
// Reserved-sidecar parent — .zddc.d/ is the one reserved namespace that
// admin APIs cannot address through a generic path (404). Other dot-
// prefixed parents are ordinary content and fall through to the
// missing-parent check below.
rec = post("root@example.com", `{"parent":"/.zddc.d", "name":"x"}`)
if rec.Code != http.StatusNotFound {
t.Errorf("reserved .zddc.d parent status=%d, want 404", rec.Code)
}
// Non-existent parent.
rec = post("root@example.com", `{"parent":"/does-not-exist", "name":"x"}`)
if rec.Code != http.StatusBadRequest {
t.Errorf("missing-parent status=%d, want 400", rec.Code)
}
// Anonymous and non-admin: 404 (no admin scope anywhere).
rec = post("", `{"parent":"/", "name":"gamma"}`)
if rec.Code != http.StatusNotFound {
t.Errorf("anonymous status=%d, want 404", rec.Code)
}
rec = post("mallory@example.com", `{"parent":"/", "name":"gamma"}`)
if rec.Code != http.StatusNotFound {
t.Errorf("non-admin status=%d, want 404", rec.Code)
}
// Method other than POST is 405.
req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil)
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET /.profile/projects status=%d, want 405", rec.Code)
}
}
// TestServeProfileProjectsCreateValidatesZddc covers ACL/admin pattern
// validation: an invalid glob in the body must roll the directory back.
func TestServeProfileProjectsCreateValidatesZddc(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 root .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
body := `{"parent":"/", "name":"badproject", "acl":{"permissions":{"bad@@glob":"rwcd"}}}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "badproject")); err == nil {
t.Errorf("dir should not exist after validation rejection")
}
}
// TestSubtreeAdminCanCreateInScope: a subtree admin (alice on /projects)
// can create /projects/sub but not /other.
func TestSubtreeAdminCanCreateInScope(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 root .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("write subtree .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "other"), 0o755); err != nil {
t.Fatalf("mkdir other: %v", err)
}
zddc.InvalidateCache(root)
zddc.InvalidateCache(filepath.Join(root, "projects"))
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
post := func(parent, name string) int {
body := `{"parent":"` + parent + `", "name":"` + name + `"}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
ctx = context.WithValue(ctx, ElevatedKey, true)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
return rec.Code
}
if code := post("/projects", "sub"); code != http.StatusCreated {
t.Errorf("alice creating /projects/sub: status=%d, want 201", code)
}
if code := post("/other", "sub"); code != http.StatusNotFound {
t.Errorf("alice creating /other/sub: status=%d, want 404 (no scope)", code)
}
}
// TestServeProfileReindexPOST exercises the happy path: admin POSTs to
// /.profile/reindex, the index rebuilds against the temp root, and the
// response carries non-zero counts plus a duration_ms field.
//
// Setup writes a transmittal folder under cfg.Root AFTER the initial empty
// index is created, so a successful Rebuild has to discover it. This mirrors
// the real-world reason the endpoint exists (an SMB-side write the watcher
// missed).
func TestServeProfileReindexPOST(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
// Empty initial index — the directory hasn't been populated yet.
idx, err := archive.BuildIndex(cfg.Root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
// Drop a transmittal folder in after the index is built. Without
// rebuild this would be invisible to the .archive resolver.
dir := filepath.Join(cfg.Root, "ProjectA", "2025-01-01_T1 (IFR) - Title")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "100_A (IFR) - Title.pdf"), []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
rec := httptest.NewRecorder()
req := requestAsAdmin(http.MethodPost, "/.profile/reindex", "alice@example.com")
ServeProfile(cfg, ring, idx, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp struct {
DurationMs int64 `json:"duration_ms"`
ProjectCount int `json:"project_count"`
TrackingCount int `json:"tracking_count"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if resp.ProjectCount != 1 {
t.Errorf("project_count=%d, want 1", resp.ProjectCount)
}
if resp.TrackingCount != 1 {
t.Errorf("tracking_count=%d, want 1", resp.TrackingCount)
}
if resp.DurationMs < 0 {
t.Errorf("duration_ms=%d, want >= 0", resp.DurationMs)
}
// The post-rebuild index must now resolve the new tracking number.
if _, ok := archive.Resolve(idx, "ProjectA", "100.html"); !ok {
t.Errorf("post-reindex: 100 should resolve")
}
}
// TestAdminPathHardCut verifies the legacy /.admin prefix is not handled
// by the server — every /.admin/* falls through to the dispatcher's normal
// path resolution which 404s on the dot-prefix guard.
func TestAdminPathHardCut(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
for _, p := range []string{"/.admin/", "/.admin/whoami", "/.admin/zddc/edit?path=/"} {
rec := httptest.NewRecorder()
req := requestAsAdmin(http.MethodGet, p, "alice@example.com")
// Calling ServeProfile directly with /.admin path: it should not match
// the /.profile prefix and so return 404. (The real-world path is
// dispatch() routing — covered in main_test.go.)
ServeProfile(cfg, ring, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q status=%d, want 404 (hard-cut)", p, rec.Code)
}
}
}