ZDDC/zddc/internal/handler/profilehandler_test.go
ZDDC 9fce18cd45 feat: lockstep release infra + cascade/.archive fixes + profile perf + page redesign
Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:

* fix(archive): nested-party + folder-type cascade
  transmittalIsUnderVisibleParty short-circuited on the first matched
  party segment, only checking the immediately-next segment for a
  folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
  toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
  any-segment party match. Eight new Playwright cases pin the contract
  in tests/archive-cascade.spec.js.

* refactor(zddc-server): scope .archive index by project
  archive.Index now buckets by top-level segment
  (.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
  take a project parameter; handler extracts it from contextPath's first
  segment. /.archive/ at root returns 404 — stable refs must be
  project-rooted. Within-project (tracking, rev) collisions emit a WARN
  with both paths. Cross-project tracking-number duplicates no longer
  collide.

* perf(zddc-server): lazy-load expensive bits of the profile page
  serveProfilePage now ships a minimal shell: Email, EmailHeader,
  IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
  editable scaffolds populate client-side via /.profile/access. Subtree-
  admin scaffolds live in <template id="tmpl-subtree-admin">; pure
  non-admins receive no live admin form. ScanZddcFiles now memoized,
  invalidated on .zddc events by the watcher and writer helpers.

* feat: lockstep release + redesigned releases page
  sh build.sh --release [version|alpha|beta] is the canonical lockstep
  cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
  version. zddc-server binaries now committed under website/releases/
  with the same cascade chain as HTML tools (no more Codeberg release-
  asset publication). zddc/release.sh deprecated (kept as a guard);
  shared/publish-codeberg-release.sh removed.

  Releases page redesigned as an action-first install guide: hero +
  version dropdown that rewires every download link, channel chips for
  always-visible alpha/beta access (state-aware labels: "tracks stable"
  vs "active dev"), Path A (zddc-server with platform auto-detect from
  UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
  narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.

  Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
  resolves at the end of every build. Bootstrap-friendly: zddc-server
  artifact checks skip until the first lockstep cut anchors the chain.

Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:11:38 -05:00

670 lines
25 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/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)
}
// requestWithEmail builds a request whose context already carries email (as
// the real ACLMiddleware would inject) and whose path is path.
func requestWithEmail(method, path, email string) *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)
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},
// 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, rec, requestWithEmail(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 := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there")
ServeProfile(cfg, ring, 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, rec, requestWithEmail(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, rec, requestWithEmail(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, rec,
requestWithEmail(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)
}
}
// stripTemplates removes every <template ...>...</template> block from the
// HTML body so substring assertions check only ACTIVE markup — i.e. live
// DOM content the user (and their browser) actually sees, as opposed to
// inert content that JS may clone in based on a later access fetch.
//
// Naive but sufficient for the controlled output of profileTemplate (the
// template tags are unnested and well-formed). If the page ever grows
// nested templates, swap this for an html.Tokenizer-based pass.
func stripTemplates(body string) string {
var b strings.Builder
for {
i := strings.Index(body, "<template")
if i < 0 {
b.WriteString(body)
return b.String()
}
b.WriteString(body[:i])
j := strings.Index(body[i:], "</template>")
if j < 0 {
// Unterminated <template> — bail; whatever's left is suspect.
return b.String()
}
body = body[i+j+len("</template>"):]
}
}
// 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, rec, requestWithEmail(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()
}
// Anonymous: identity says "Not signed in", no live admin markup, no
// diagnostics. The <template> still ships inertly so any caller could
// hydrate it after a successful /access fetch — but a non-admin's
// /access response carries empty AdminSubtrees and the JS skips
// instantiation. The active-markup check below proves the live DOM is
// admin-clean regardless.
anon := render("")
if !strings.Contains(anon, "Not signed in") {
t.Errorf("anonymous body missing 'Not signed in'")
}
anonActive := stripTemplates(anon)
for _, marker := range []string{
`<form id="cp-form"`,
`id="diag-config"`,
`id="diag-logs"`,
`id="diag-whoami"`,
"Server config",
} {
if strings.Contains(anonActive, marker) {
t.Errorf("anonymous active markup unexpectedly contains admin marker %q", marker)
}
}
// Inert <template> SHOULD ship — admins (and only admins) hydrate it.
if !strings.Contains(anon, `<template id="tmpl-subtree-admin">`) {
t.Errorf("anonymous body missing inert subtree-admin <template>")
}
nonAdmin := render("carol@example.com")
if !strings.Contains(nonAdmin, "carol@example.com") {
t.Errorf("non-admin body missing email")
}
nonAdminActive := stripTemplates(nonAdmin)
for _, marker := range []string{
`<form id="cp-form"`,
`id="diag-config"`,
"Server config",
} {
if strings.Contains(nonAdminActive, marker) {
t.Errorf("non-admin active markup unexpectedly contains admin marker %q", marker)
}
}
// Subtree-admin (bob) gets the same shell as a non-admin — the
// scaffold lives in the <template> and JS hydrates it after fetching
// /.profile/access. The server-side render no longer differentiates
// these two roles, so its byte-output should match a non-admin's.
subtree := render("bob@example.com")
subtreeActive := stripTemplates(subtree)
for _, marker := range []string{
`<form id="cp-form"`,
`id="diag-config"`,
"Server config",
} {
if strings.Contains(subtreeActive, marker) {
t.Errorf("subtree-admin active markup unexpectedly contains admin marker %q (these are JS-hydrated)", marker)
}
}
if !strings.Contains(subtree, `<template id="tmpl-subtree-admin">`) {
t.Errorf("subtree-admin body missing the <template> the IIFE will hydrate")
}
// Super-admin: diagnostics scaffold is rendered inline (cheap to
// gate), AND the subtree-admin <template> still ships for the IIFE to
// hydrate Editable + Create sections.
super := render("alice@example.com")
superActive := stripTemplates(super)
for _, marker := range []string{
"Server config",
`id="diag-config"`,
`id="diag-logs"`,
`id="diag-whoami"`,
} {
if !strings.Contains(superActive, marker) {
t.Errorf("super-admin active markup missing %q", marker)
}
}
if !strings.Contains(super, `<template id="tmpl-subtree-admin">`) {
t.Errorf("super-admin body missing subtree-admin <template> (still needs to hydrate Editable + Create)")
}
}
func TestServeProfileAccessJSON(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(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 everything
// the IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
// for the read-only list, EditableParentChoices for 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, rec, requestWithEmail(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)
}
if len(carol.EditableParentChoices) != 0 {
t.Errorf("carol EditableParentChoices = %v, want empty", carol.EditableParentChoices)
}
// Subtree-admin: AdminSubtrees lists projects/ so the create-project
// parent dropdown can offer it; HasAnyAdminScope triggers template
// hydration. The projects/.zddc is NOT editable by bob — he cannot
// edit the file that grants him his own authority — so
// EditableParentChoices is empty and the Editable-files list will
// render its "None" placeholder.
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 s.CanEdit {
t.Errorf("bob's projects/ entry CanEdit = true; he should not be able to edit the .zddc granting his own authority")
}
}
}
if !gotProjects {
t.Errorf("bob AdminSubtrees missing projects/: %+v", bob.AdminSubtrees)
}
if len(bob.EditableParentChoices) != 0 {
t.Errorf("bob EditableParentChoices = %+v, want empty (his only subtree is one he can't edit)", bob.EditableParentChoices)
}
// 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)
}
}
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 allow: [\"*\"]\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(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, rec, requestWithEmail(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 != "" {
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, req)
return rec
}
// Happy path: super-admin creates /alpha with no .zddc body.
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 NOT be auto-written when no fields supplied")
}
// 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-prefix parent.
rec = post("root@example.com", `{"parent":"/.foo", "name":"x"}`)
if rec.Code != http.StatusNotFound {
t.Errorf("reserved-prefix 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)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, 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":{"allow":["bad@@glob"], "deny":[]}}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), 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")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), 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)
}
}
// 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 := requestWithEmail(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, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q status=%d, want 404 (hard-cut)", p, rec.Code)
}
}
}