ZDDC/zddc/internal/handler/adminhandler_test.go
ZDDC 9ef90800b1 feat(zddc-server): admin debug page + X-Auth-Request-Email default + hidden-segment guard
Three improvements bundled because they all ship as zddc-server v0.0.2:

* /.admin/ debug dashboard with /whoami, /config, /logs sub-routes.
  Authorization via a top-level `admins:` glob list in <ZDDC_ROOT>/.zddc
  (root-only — subdir entries deliberately ignored to prevent privilege
  escalation via subtree write access). Non-admin requests get 404 so the
  page is invisible. Recent logs surface via a 500-entry slog ring buffer
  teed off the existing TextHandler. Lets operators debug without
  kubectl exec.

* Default ZDDC_EMAIL_HEADER changes from `X-Email` to
  `X-Auth-Request-Email` — the oauth2-proxy / nginx auth-request
  convention that the TND helm chart already sets explicitly.
  Operators who set the env var explicitly are unaffected; deployments
  relying on the previous default need to set ZDDC_EMAIL_HEADER=X-Email
  or update their proxy.

* dispatch() rejects any URL whose segments contain a dot prefix other
  than the recognized virtual prefixes (.admin, cfg.IndexPath /
  .archive). Matches the existing listing-pipeline filter so hidden
  subtrees on the served PVC (e.g. /srv/.devshell — used by the
  in-cluster dev-shell for persistent home-dir state) become
  unreachable via direct HTTP fetch, not just hidden in listings.

Refreshes the X-Email reference in website/index.html accordingly.

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

232 lines
7.5 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"
)
// adminTestRoot creates a temp dir, writes a .zddc with the given admins
// list, and returns a Config pointing at it.
func adminTestRoot(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)
}
}
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
}
func TestServeAdminAuthGate(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
cases := []struct {
name string
path string
email string
wantStatus int
}{
// Anonymous (no email) — every path is hidden.
{"anonymous /.admin/", "/.admin/", "", http.StatusNotFound},
{"anonymous /.admin/whoami", "/.admin/whoami", "", http.StatusNotFound},
{"anonymous /.admin/config", "/.admin/config", "", http.StatusNotFound},
{"anonymous /.admin/logs", "/.admin/logs", "", http.StatusNotFound},
// Logged-in non-admin — 404 (existence not leaked).
{"non-admin /.admin/", "/.admin/", "bob@example.com", http.StatusNotFound},
{"non-admin /.admin/whoami", "/.admin/whoami", "bob@example.com", http.StatusNotFound},
// Admin — every defined path responds 200.
{"admin /.admin/", "/.admin/", "alice@example.com", http.StatusOK},
{"admin /.admin/whoami", "/.admin/whoami", "alice@example.com", http.StatusOK},
{"admin /.admin/config", "/.admin/config", "alice@example.com", http.StatusOK},
{"admin /.admin/logs", "/.admin/logs", "alice@example.com", http.StatusOK},
// Admin hitting an undefined sub-route — 404.
{"admin unknown subroute", "/.admin/nope", "alice@example.com", http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
ServeAdmin(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 TestServeAdminWhoamiPayload(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
r := requestWithEmail(http.MethodGet, "/.admin/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there")
ServeAdmin(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 TestServeAdminConfigPayload(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
cfg.LogLevel = "info"
cfg.IndexPath = ".archive"
cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/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 TestServeAdminLogsPayload(t *testing.T) {
cfg, ring := adminTestRoot(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()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/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 TestServeAdminLogsLevelFilter(t *testing.T) {
cfg, ring := adminTestRoot(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()
ServeAdmin(cfg, ring, rec,
requestWithEmail(http.MethodGet, "/.admin/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)
}
}
func TestServeAdminDashboardHTML(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
body := rec.Body.String()
for _, want := range []string{"<!DOCTYPE html>", "/.admin/", `data-target="whoami"`, `data-target="config"`, `data-target="logs"`} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
}
func TestServeAdminNoAdminsConfiguredHidesEverything(t *testing.T) {
// .zddc exists but has no admins list — page is invisible to all.
cfg, ring := adminTestRoot(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)
}
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/whoami", "alice@example.com"))
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (no admins configured)", rec.Code)
}
}