Four targeted test suites that pin the invariants exercised by the
preceding audit refactor. Closes the coverage gaps identified after the
admin-decider consolidation and the .zddc write-path fix.
internal/policy/principal_test.go (NEW)
TestAllowActionFromChainP_TruthTable — 11 cases × 5 actions = 55
assertions covering every (elevated × admin-at-level × action)
combination. Pins the IsActiveAdmin short-circuit: bypass requires
BOTH (in admins) AND Elevated; elevation alone confers nothing;
empty email never matches.
TestAllowActionFromChainP_AdminScopeDepth — root admin reaches every
path; subtree admin matches in their own subtree; subtree admin
does NOT match in a sibling subtree (the chain doesn't carry
sibling admins lists).
TestAllowActionFromChainP_BypassWinsOverWorm — elevated admin
escape hatch in WORM zones, plus the negative control that an
un-elevated admin does NOT bypass WORM.
internal/handler/auth_invariants_test.go (appended)
TestInvariant_ZddcPutMatrix — 16 sub-cases across (root / project /
subtree .zddc) × (root admin / subtree admin / non-admin /
anonymous) × (elevated / un-elevated). Locks down which principal
can PUT which .zddc.
TestInvariant_ZddcDeleteMatrix — 5 DELETE cases.
TestInvariant_UnelevatedAdminNoSilentBypass — 14 anti-bypass probes:
every (admin-flavour × probe-path) tuple where an un-elevated
admin must 403. Single bypass leak → loud test failure.
cmd/zddc-server/main_test.go (appended)
TestDispatchZddcWriteRouting — full dispatcher path coverage:
GET/HEAD route to ServeZddcFile (YAML or virtual placeholder);
PUT/DELETE route through the .zddc-leaf carve-out into
ServeFileAPI; intermediate .zddc.d/ segments still 404 at the
guard.
internal/handler/middleware_test.go (appended)
TestAccessLog_ChainAdminLevelAttribution — 7 cases pinning the
forensic record: root admin → chain_admin_level=0, subtree admin
in scope → chain_admin_level=N, subtree admin out of scope → -1,
un-elevated admin → -1, non-admin → -1, anonymous → -1.
Cross-checks active_admin == (chain_admin_level >= 0) so a future
refactor can't desync them.
92 new sub-cases total. Coverage delta on the policy package:
76.1% → 87.2%; AllowActionFromChainP 0% → 100%;
activeAdminForRequest 7% → 68%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
252 lines
10 KiB
Go
252 lines
10 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)
|
|
}
|
|
})
|
|
}
|
|
}
|