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>
498 lines
18 KiB
Go
498 lines
18 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)
|
|
}
|
|
}
|
|
|
|
// TestServeProfileHTMLLayered verifies server-side conditional rendering:
|
|
// non-admin HTML contains zero admin markup, admin HTML adds the admin
|
|
// block, super-admin HTML adds the diagnostics block.
|
|
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()
|
|
}
|
|
|
|
anon := render("")
|
|
if !strings.Contains(anon, "Not signed in") {
|
|
t.Errorf("anonymous body missing 'Not signed in'")
|
|
}
|
|
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs"} {
|
|
if strings.Contains(anon, marker) {
|
|
t.Errorf("anonymous body unexpectedly contains admin marker %q", marker)
|
|
}
|
|
}
|
|
|
|
nonAdmin := render("carol@example.com")
|
|
if !strings.Contains(nonAdmin, "carol@example.com") {
|
|
t.Errorf("non-admin body missing email")
|
|
}
|
|
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config"} {
|
|
if strings.Contains(nonAdmin, marker) {
|
|
t.Errorf("non-admin body unexpectedly contains admin marker %q", marker)
|
|
}
|
|
}
|
|
|
|
subtree := render("bob@example.com")
|
|
if !strings.Contains(subtree, "Editable .zddc files") {
|
|
t.Errorf("subtree-admin body missing 'Editable .zddc files'")
|
|
}
|
|
if !strings.Contains(subtree, "Create new project folder") {
|
|
t.Errorf("subtree-admin body missing 'Create new project folder'")
|
|
}
|
|
if strings.Contains(subtree, "Server config") {
|
|
t.Errorf("subtree-admin body unexpectedly contains super-admin diagnostics")
|
|
}
|
|
|
|
super := render("alice@example.com")
|
|
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs", "diag-whoami"} {
|
|
if !strings.Contains(super, marker) {
|
|
t.Errorf("super-admin body missing %q", marker)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|