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>
232 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|