ZDDC/zddc/internal/handler/middleware_test.go
ZDDC c59bea183e feat(server): honor ?admin=true|false elevation on every endpoint
shared/elevation.js toggles admin mode via the ?admin= URL param, but it's
client-side JS — it only runs on HTML tool pages, where it sets the sticky
zddc-elevate cookie. A raw endpoint (a directory's JSON listing, zip
browsing at /<…>.zip/, the file API) loads no JS, so ?admin=true was inert
there and such requests stayed un-elevated.

ACLMiddleware now reads the same ?admin= toggle directly: true|1|on|yes
elevates the request, false|0|off|no drops it (overriding the cookie for
that request). This is per-request only — the server doesn't set/clear the
cookie; elevation.js still owns sticky persistence on pages. Elevation
grants powers only to a caller who already holds admin authority (every
admin call site re-checks via IsActiveAdmin), so a non-admin's ?admin=true
sets the forensic flag but confers nothing.

Makes e.g. GET /.zddc.zip/?admin=true work for an admin without first
arming the cookie on a page.

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

322 lines
13 KiB
Go

package handler
import (
"bytes"
"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"
)
// TestAccessLogReadsEmailFromACLContext is a regression test for a bug where
// the access-log middleware logged email=anonymous on every request because
// it sat OUTSIDE the ACL middleware in the chain — Go's context propagates
// down via r.WithContext, not back up through the call chain, so an outer
// middleware can't read a context value set by an inner one after
// next.ServeHTTP returns. Fix: ACLMiddleware must wrap AccessLogMiddleware
// (ACL outer), not the other way around.
func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
// Capture slog output so we can assert on what AccessLogMiddleware logged.
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(prev)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
// email from the context ACL populated.
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
chain.ServeHTTP(httptest.NewRecorder(), req)
got := buf.String()
if !strings.Contains(got, `email=alice@example.com`) {
t.Errorf("expected access log to contain email=alice@example.com, got: %s", got)
}
if strings.Contains(got, `email=anonymous`) {
t.Errorf("access log fell back to email=anonymous despite header being set; ACL/AccessLog order may have regressed: %s", got)
}
}
// TestAccessLogAnonymousWhenNoEmail confirms that when the configured email
// header is absent, the access log still records email=anonymous as expected.
func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(prev)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
// Note: no X-Auth-Request-Email header set.
chain.ServeHTTP(httptest.NewRecorder(), req)
got := buf.String()
if !strings.Contains(got, `email=anonymous`) {
t.Errorf("expected access log to contain email=anonymous when header absent, got: %s", got)
}
}
// TestAccessLogOuterDoesNotSeeInnerContext is a guard test that locks down
// the underlying Go behavior: putting AccessLog OUTER and ACL INNER produces
// the original bug (email=anonymous despite the header being set). If this
// test ever fails, Go's context propagation has changed in a way that lets
// inner-middleware context values flow back up — which would mean the
// reordering fix can be reverted.
func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(prev)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Inverted order — the ORIGINAL buggy chain.
chain := AccessLogMiddleware(cfg, nil, ACLMiddleware(cfg, nil, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
chain.ServeHTTP(httptest.NewRecorder(), req)
got := buf.String()
if strings.Contains(got, `email=alice@example.com`) {
t.Fatalf("Go's context propagation behavior changed — AccessLog (outer) somehow saw the email ACL (inner) set. The middleware reordering in main.go is no longer required and could be reverted. Log: %s", got)
}
if !strings.Contains(got, `email=anonymous`) {
t.Errorf("expected the inverted (buggy) chain to fall back to email=anonymous, got: %s", got)
}
}
// TestAccessLogMiddleware_AuditLoggerReceivesSameFields verifies the
// optional audit-logger argument: when non-nil, it gets a parallel copy
// of every access record. Used by main.go to tee access logs to a
// rotating file in addition to stderr.
func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
var auditBuf bytes.Buffer
auditLogger := slog.New(slog.NewJSONHandler(&auditBuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write([]byte("hi"))
})
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
req.Header.Set("X-Auth-Request-Email", "bob@example.com")
chain.ServeHTTP(httptest.NewRecorder(), req)
out := auditBuf.String()
if !strings.Contains(out, `"email":"bob@example.com"`) {
t.Errorf("audit log missing email field; got: %s", out)
}
if !strings.Contains(out, `"path":"/some/path"`) {
t.Errorf("audit log missing path; got: %s", out)
}
if !strings.Contains(out, `"status":418`) {
t.Errorf("audit log missing status code; got: %s", out)
}
}
// TestAccessLog_ChainAdminLevelAttribution pins the audit-log forensic
// invariant: every request record carries `chain_admin_level` matching
// the .zddc admins: level that conferred admin authority on this
// request, or -1 when no admin authority applies. Forensics use this to
// distinguish a root-admin write from a subtree-admin write from a
// non-admin write — three operationally distinct events that used to
// be conflated under a single `is_admin` boolean.
//
// Truth table the middleware must emit:
//
// (elevated, in admins at level N) → chain_admin_level=N, active_admin=true
// (elevated, in admins at no level) → chain_admin_level=-1, active_admin=false
// (not elevated, in admins) → chain_admin_level=-1, active_admin=false
// (anonymous, elevation flag ignored) → chain_admin_level=-1, active_admin=false
func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) {
// Fixture: root admin at level 0; subtree admin at level 1 (Project-1).
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, "Project-1"), 0o755); err != nil {
t.Fatalf("mkdir Project-1: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "Project-1", ".zddc"),
[]byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("write subtree .zddc: %v", err)
}
zddc.InvalidateCache(root)
zddc.InvalidateCache(filepath.Join(root, "Project-1"))
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
type record struct {
Email string `json:"email"`
Elevated bool `json:"elevated"`
ActiveAdmin bool `json:"active_admin"`
ChainAdminLevel int `json:"chain_admin_level"`
Path string `json:"path"`
}
parse := func(t *testing.T, buf *bytes.Buffer) record {
t.Helper()
var rec record
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("audit log not valid JSON: %v; raw=%s", err, buf.String())
}
return rec
}
cases := []struct {
name string
email string
elevate bool
path string
wantLevel int
wantActive bool
}{
{"root admin elevated probing root → level 0", "root@example.com", true, "/", 0, true},
{"root admin elevated probing project → level 0 (walks down chain)", "root@example.com", true, "/Project-1/", 0, true},
{"subtree admin elevated probing own subtree → level 1", "alice@example.com", true, "/Project-1/", 1, true},
{"subtree admin elevated probing root → -1 (out of scope)", "alice@example.com", true, "/", -1, false},
{"root admin un-elevated → -1 (no live authority)", "root@example.com", false, "/", -1, false},
{"non-admin elevated → -1 (elevation alone confers nothing)", "stranger@example.com", true, "/", -1, false},
{"anonymous → -1", "", false, "/", -1, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
auditLogger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
if tc.email != "" {
req.Header.Set("X-Auth-Request-Email", tc.email)
}
if tc.elevate {
req.AddCookie(&http.Cookie{Name: "zddc-elevate", Value: "1"})
}
chain.ServeHTTP(httptest.NewRecorder(), req)
rec := parse(t, &buf)
if rec.ChainAdminLevel != tc.wantLevel {
t.Errorf("chain_admin_level = %d, want %d", rec.ChainAdminLevel, tc.wantLevel)
}
if rec.ActiveAdmin != tc.wantActive {
t.Errorf("active_admin = %v, want %v", rec.ActiveAdmin, tc.wantActive)
}
// active_admin is the projection of chain_admin_level — these
// two fields must agree on every record. Asserted explicitly
// so a future refactor that drops the chain_admin_level field
// (or recomputes active_admin from a different source) trips
// this test before the forensic invariant rots.
if rec.ActiveAdmin != (rec.ChainAdminLevel >= 0) {
t.Errorf("active_admin must equal (chain_admin_level >= 0); got active=%v level=%d",
rec.ActiveAdmin, rec.ChainAdminLevel)
}
// Elevation flag must round-trip independently — distinguishes
// "tried to elevate, no authority" (elevated=true, active=false)
// from "didn't elevate" (elevated=false, active=false).
if rec.Elevated != tc.elevate {
t.Errorf("elevated = %v, want %v", rec.Elevated, tc.elevate)
}
})
}
}
// TestACLMiddleware_AdminQueryParamElevation verifies the server honors the
// ?admin= URL toggle directly (mirroring shared/elevation.js), so the param
// elevates ANY endpoint — not just HTML pages where elevation.js runs to set
// the cookie. ?admin=true elevates with no cookie; ?admin=false drops even
// when the cookie is present; a non-admin's ?admin=true sets the flag but
// confers no authority.
func TestACLMiddleware_AdminQueryParamElevation(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"}
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
type record struct {
Elevated bool `json:"elevated"`
ActiveAdmin bool `json:"active_admin"`
}
run := func(t *testing.T, path, email string, cookie bool) record {
t.Helper()
var buf bytes.Buffer
auditLogger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, path, nil)
if email != "" {
req.Header.Set("X-Auth-Request-Email", email)
}
if cookie {
req.AddCookie(&http.Cookie{Name: "zddc-elevate", Value: "1"})
}
chain.ServeHTTP(httptest.NewRecorder(), req)
var rec record
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("audit log not JSON: %v; raw=%s", err, buf.String())
}
return rec
}
t.Run("?admin=true elevates root admin with no cookie", func(t *testing.T) {
rec := run(t, "/?admin=true", "root@example.com", false)
if !rec.Elevated || !rec.ActiveAdmin {
t.Errorf("elevated=%v active=%v, want both true", rec.Elevated, rec.ActiveAdmin)
}
})
t.Run("?admin=false drops despite cookie", func(t *testing.T) {
rec := run(t, "/?admin=false", "root@example.com", true)
if rec.Elevated || rec.ActiveAdmin {
t.Errorf("elevated=%v active=%v, want both false", rec.Elevated, rec.ActiveAdmin)
}
})
t.Run("non-admin ?admin=true sets flag but confers no authority", func(t *testing.T) {
rec := run(t, "/?admin=true", "stranger@example.com", false)
if !rec.Elevated {
t.Errorf("elevated=%v, want true (flag set)", rec.Elevated)
}
if rec.ActiveAdmin {
t.Errorf("active_admin=%v, want false (no admin authority)", rec.ActiveAdmin)
}
})
t.Run("no param, no cookie → not elevated", func(t *testing.T) {
rec := run(t, "/", "root@example.com", false)
if rec.Elevated || rec.ActiveAdmin {
t.Errorf("elevated=%v active=%v, want both false", rec.Elevated, rec.ActiveAdmin)
}
})
}