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) } }) }