Browse Source

feat(security): wave 7 - security headers integration, regression suite, quality gates, migration runbook, go-live checklist

ding113 3 weeks ago
parent
commit
f312071e9a

+ 30 - 9
src/app/api/auth/login/route.ts

@@ -15,6 +15,7 @@ import { getEnvConfig } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
 import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard";
 import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy";
+import { buildSecurityHeaders } from "@/lib/security/security-headers";
 
 // 需要数据库连接
 export const runtime = "nodejs";
@@ -27,6 +28,24 @@ const csrfGuard = createCsrfOriginGuard({
 
 const loginPolicy = new LoginAbusePolicy();
 
+function applySecurityHeaders(response: NextResponse): NextResponse {
+  const env = getEnvConfig();
+  const headers = buildSecurityHeaders({
+    enableHsts: env.ENABLE_SECURE_COOKIES,
+    cspMode: "report-only",
+  });
+
+  for (const [key, value] of Object.entries(headers)) {
+    response.headers.set(key, value);
+  }
+
+  return response;
+}
+
+function withAuthResponseHeaders(response: NextResponse): NextResponse {
+  return applySecurityHeaders(withNoStoreHeaders(response));
+}
+
 /**
  * Get locale from request (cookie or Accept-Language header)
  */
@@ -127,7 +146,7 @@ async function createOpaqueSession(key: string, session: AuthSession) {
 export async function POST(request: NextRequest) {
   const csrfResult = csrfGuard.check(request);
   if (!csrfResult.allowed) {
-    return withNoStoreHeaders(
+    return withAuthResponseHeaders(
       NextResponse.json({ error: "Forbidden", errorCode: "CSRF_REJECTED" }, { status: 403 })
     );
   }
@@ -138,7 +157,7 @@ export async function POST(request: NextRequest) {
 
   const decision = loginPolicy.check(clientIp);
   if (!decision.allowed) {
-    const response = withNoStoreHeaders(
+    const response = withAuthResponseHeaders(
       NextResponse.json(
         {
           error: t?.("loginFailed") ?? t?.("serverError"),
@@ -160,12 +179,12 @@ export async function POST(request: NextRequest) {
 
     if (!key) {
       if (!shouldIncludeFailureTaxonomy(request)) {
-        return withNoStoreHeaders(
+        return withAuthResponseHeaders(
           NextResponse.json({ error: t?.("apiKeyRequired") }, { status: 400 })
         );
       }
 
-      return withNoStoreHeaders(
+      return withAuthResponseHeaders(
         NextResponse.json(
           { error: t?.("apiKeyRequired"), errorCode: "KEY_REQUIRED" },
           { status: 400 }
@@ -178,7 +197,7 @@ export async function POST(request: NextRequest) {
       loginPolicy.recordFailure(clientIp);
 
       if (!shouldIncludeFailureTaxonomy(request)) {
-        return withNoStoreHeaders(
+        return withAuthResponseHeaders(
           NextResponse.json({ error: t?.("apiKeyInvalidOrExpired") }, { status: 401 })
         );
       }
@@ -200,7 +219,7 @@ export async function POST(request: NextRequest) {
           t?.("serverError");
       }
 
-      return withNoStoreHeaders(NextResponse.json(responseBody, { status: 401 }));
+      return withAuthResponseHeaders(NextResponse.json(responseBody, { status: 401 }));
     }
 
     const mode = getSessionTokenMode();
@@ -230,7 +249,7 @@ export async function POST(request: NextRequest) {
           ? "dashboard_user"
           : "readonly_user";
 
-    return withNoStoreHeaders(
+    return withAuthResponseHeaders(
       NextResponse.json({
         ok: true,
         user: {
@@ -247,10 +266,12 @@ export async function POST(request: NextRequest) {
     logger.error("Login error:", error);
 
     if (!shouldIncludeFailureTaxonomy(request)) {
-      return withNoStoreHeaders(NextResponse.json({ error: t?.("serverError") }, { status: 500 }));
+      return withAuthResponseHeaders(
+        NextResponse.json({ error: t?.("serverError") }, { status: 500 })
+      );
     }
 
-    return withNoStoreHeaders(
+    return withAuthResponseHeaders(
       NextResponse.json({ error: t?.("serverError"), errorCode: "SERVER_ERROR" }, { status: 500 })
     );
   }

+ 22 - 2
src/app/api/auth/logout/route.ts

@@ -7,8 +7,10 @@ import {
   withNoStoreHeaders,
 } from "@/lib/auth";
 import { RedisSessionStore } from "@/lib/auth-session-store/redis-session-store";
+import { getEnvConfig } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
 import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard";
+import { buildSecurityHeaders } from "@/lib/security/security-headers";
 
 const csrfGuard = createCsrfOriginGuard({
   allowedOrigins: [],
@@ -27,10 +29,28 @@ async function resolveAuthCookieToken(): Promise<string | undefined> {
   return reader();
 }
 
+function applySecurityHeaders(response: NextResponse): NextResponse {
+  const env = getEnvConfig();
+  const headers = buildSecurityHeaders({
+    enableHsts: env.ENABLE_SECURE_COOKIES,
+    cspMode: "report-only",
+  });
+
+  for (const [key, value] of Object.entries(headers)) {
+    response.headers.set(key, value);
+  }
+
+  return response;
+}
+
+function withAuthResponseHeaders(response: NextResponse): NextResponse {
+  return applySecurityHeaders(withNoStoreHeaders(response));
+}
+
 export async function POST(request: NextRequest) {
   const csrfResult = csrfGuard.check(request);
   if (!csrfResult.allowed) {
-    return withNoStoreHeaders(
+    return withAuthResponseHeaders(
       NextResponse.json({ error: "Forbidden", errorCode: "CSRF_REJECTED" }, { status: 403 })
     );
   }
@@ -52,5 +72,5 @@ export async function POST(request: NextRequest) {
   }
 
   await clearAuthCookie();
-  return withNoStoreHeaders(NextResponse.json({ ok: true }));
+  return withAuthResponseHeaders(NextResponse.json({ ok: true }));
 }

+ 283 - 0
tests/security/full-security-regression.test.ts

@@ -0,0 +1,283 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createCsrfOriginGuard } from "../../src/lib/security/csrf-origin-guard";
+import { LoginAbusePolicy } from "../../src/lib/security/login-abuse-policy";
+import {
+  buildSecurityHeaders,
+  DEFAULT_SECURITY_HEADERS_CONFIG,
+} from "../../src/lib/security/security-headers";
+
+const mockCookieSet = vi.hoisted(() => vi.fn());
+const mockCookies = vi.hoisted(() => vi.fn());
+const mockGetRedisClient = vi.hoisted(() => vi.fn());
+
+vi.mock("next/headers", () => ({
+  cookies: mockCookies,
+  headers: vi.fn().mockResolvedValue(new Headers()),
+}));
+
+vi.mock("@/lib/config/config", () => ({
+  config: {
+    auth: {
+      adminToken: "test-admin-token",
+    },
+  },
+}));
+
+vi.mock("@/repository/key", () => ({
+  findKeyList: vi.fn(),
+  validateApiKeyAndGetUser: vi.fn(),
+}));
+
+vi.mock("@/lib/redis", () => ({
+  getRedisClient: mockGetRedisClient,
+}));
+
+const ORIGINAL_SESSION_TOKEN_MODE = process.env.SESSION_TOKEN_MODE;
+const ORIGINAL_ENABLE_SECURE_COOKIES = process.env.ENABLE_SECURE_COOKIES;
+
+function restoreAuthEnv() {
+  if (ORIGINAL_SESSION_TOKEN_MODE === undefined) {
+    delete process.env.SESSION_TOKEN_MODE;
+  } else {
+    process.env.SESSION_TOKEN_MODE = ORIGINAL_SESSION_TOKEN_MODE;
+  }
+
+  if (ORIGINAL_ENABLE_SECURE_COOKIES === undefined) {
+    delete process.env.ENABLE_SECURE_COOKIES;
+  } else {
+    process.env.ENABLE_SECURE_COOKIES = ORIGINAL_ENABLE_SECURE_COOKIES;
+  }
+}
+
+function setupCookieStoreMock() {
+  mockCookieSet.mockClear();
+  mockCookies.mockResolvedValue({
+    set: mockCookieSet,
+    get: vi.fn(),
+    delete: vi.fn(),
+  });
+}
+
+class FakeRedisClient {
+  status: "ready" = "ready";
+  private readonly values = new Map<string, string>();
+
+  async setex(key: string, _ttl: number, value: string): Promise<"OK"> {
+    this.values.set(key, value);
+    return "OK";
+  }
+
+  async get(key: string): Promise<string | null> {
+    return this.values.get(key) ?? null;
+  }
+
+  async del(key: string): Promise<number> {
+    return this.values.delete(key) ? 1 : 0;
+  }
+}
+
+describe("Full Security Regression Suite", () => {
+  beforeEach(() => {
+    setupCookieStoreMock();
+  });
+
+  afterEach(() => {
+    restoreAuthEnv();
+    vi.useRealTimers();
+    vi.clearAllMocks();
+    vi.resetModules();
+  });
+
+  describe("Session Contract", () => {
+    it("SESSION_TOKEN_MODE defaults to legacy", async () => {
+      delete process.env.SESSION_TOKEN_MODE;
+
+      vi.resetModules();
+      const { getSessionTokenMode } = await import("../../src/lib/auth");
+
+      expect(getSessionTokenMode()).toBe("legacy");
+    });
+
+    it("OpaqueSessionContract has required fields", async () => {
+      vi.resetModules();
+      const { isOpaqueSessionContract } = await import("../../src/lib/auth");
+
+      const contract = {
+        sessionId: "sid_opaque_session_123",
+        keyFingerprint: "sha256:abc123",
+        createdAt: 1_700_000_000,
+        expiresAt: 1_700_000_300,
+        userId: 42,
+        userRole: "admin",
+      };
+
+      expect(isOpaqueSessionContract(contract)).toBe(true);
+
+      const missingUserRole = { ...contract } as Partial<typeof contract>;
+      delete missingUserRole.userRole;
+      expect(isOpaqueSessionContract(missingUserRole)).toBe(false);
+    });
+  });
+
+  describe("Session Store", () => {
+    it("create returns valid session data", async () => {
+      const redis = new FakeRedisClient();
+      mockGetRedisClient.mockReturnValue(redis);
+      const { RedisSessionStore } = await import(
+        "../../src/lib/auth-session-store/redis-session-store"
+      );
+
+      const store = new RedisSessionStore();
+
+      const created = await store.create({
+        keyFingerprint: "sha256:fp-1",
+        userId: 101,
+        userRole: "user",
+      });
+
+      expect(created.sessionId).toMatch(/^[0-9a-f-]{36}$/i);
+      expect(created.keyFingerprint).toBe("sha256:fp-1");
+      expect(created.userId).toBe(101);
+      expect(created.userRole).toBe("user");
+      expect(created.expiresAt).toBeGreaterThan(created.createdAt);
+      await expect(store.read(created.sessionId)).resolves.toEqual(created);
+    });
+
+    it("read returns null for non-existent session", async () => {
+      const redis = new FakeRedisClient();
+      mockGetRedisClient.mockReturnValue(redis);
+      const { RedisSessionStore } = await import(
+        "../../src/lib/auth-session-store/redis-session-store"
+      );
+
+      const store = new RedisSessionStore();
+
+      await expect(store.read("missing-session")).resolves.toBeNull();
+    });
+  });
+
+  describe("Cookie Hardening", () => {
+    it("auth cookie is HttpOnly", async () => {
+      process.env.ENABLE_SECURE_COOKIES = "true";
+
+      vi.resetModules();
+      const { AUTH_COOKIE_NAME, setAuthCookie } = await import("../../src/lib/auth");
+
+      await setAuthCookie("test-key");
+
+      expect(mockCookieSet).toHaveBeenCalledTimes(1);
+      const [name, value, options] = mockCookieSet.mock.calls[0];
+      expect(name).toBe(AUTH_COOKIE_NAME);
+      expect(value).toBe("test-key");
+      expect(options.httpOnly).toBe(true);
+    });
+
+    it("auth cookie secure flag matches env", async () => {
+      const cases = [
+        { envValue: "true", expected: true },
+        { envValue: "false", expected: false },
+      ] as const;
+
+      for (const testCase of cases) {
+        mockCookieSet.mockClear();
+        process.env.ENABLE_SECURE_COOKIES = testCase.envValue;
+
+        vi.resetModules();
+        const { setAuthCookie } = await import("../../src/lib/auth");
+        await setAuthCookie("env-test");
+
+        const [, , options] = mockCookieSet.mock.calls[0];
+        expect(options.secure).toBe(testCase.expected);
+      }
+    });
+  });
+
+  describe("Anti-Bruteforce", () => {
+    it("blocks after threshold", () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z"));
+
+      const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 2, lockoutSeconds: 60 });
+      const ip = "198.51.100.10";
+
+      policy.recordFailure(ip);
+      policy.recordFailure(ip);
+
+      const decision = policy.check(ip);
+      expect(decision.allowed).toBe(false);
+      expect(decision.reason).toBe("ip_rate_limited");
+      expect(decision.retryAfterSeconds).toBeGreaterThan(0);
+    });
+
+    it("resets on success", () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z"));
+
+      const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 2, lockoutSeconds: 60 });
+      const ip = "198.51.100.11";
+
+      policy.recordFailure(ip);
+      policy.recordFailure(ip);
+      expect(policy.check(ip).allowed).toBe(false);
+
+      policy.recordSuccess(ip);
+      expect(policy.check(ip)).toEqual({ allowed: true });
+    });
+  });
+
+  describe("CSRF Guard", () => {
+    it("allows same-origin", () => {
+      const guard = createCsrfOriginGuard({
+        allowedOrigins: ["https://safe.example.com"],
+        allowSameOrigin: true,
+        enforceInDevelopment: true,
+      });
+
+      const result = guard.check({
+        headers: new Headers({
+          "sec-fetch-site": "same-origin",
+        }),
+      });
+
+      expect(result).toEqual({ allowed: true });
+    });
+
+    it("blocks cross-origin", () => {
+      const guard = createCsrfOriginGuard({
+        allowedOrigins: ["https://safe.example.com"],
+        allowSameOrigin: true,
+        enforceInDevelopment: true,
+      });
+
+      const result = guard.check({
+        headers: new Headers({
+          "sec-fetch-site": "cross-site",
+          origin: "https://evil.example.com",
+        }),
+      });
+
+      expect(result.allowed).toBe(false);
+      expect(result.reason).toBe("Origin https://evil.example.com not in allowlist");
+    });
+  });
+
+  describe("Security Headers", () => {
+    it("includes all required headers", () => {
+      const headers = buildSecurityHeaders();
+
+      expect(headers["X-Content-Type-Options"]).toBe("nosniff");
+      expect(headers["X-Frame-Options"]).toBe(DEFAULT_SECURITY_HEADERS_CONFIG.frameOptions);
+      expect(headers["Referrer-Policy"]).toBe("strict-origin-when-cross-origin");
+      expect(headers["X-DNS-Prefetch-Control"]).toBe("off");
+      expect(headers["Content-Security-Policy-Report-Only"]).toContain("default-src 'self'");
+    });
+
+    it("CSP report-only by default", () => {
+      expect(DEFAULT_SECURITY_HEADERS_CONFIG.cspMode).toBe("report-only");
+
+      const headers = buildSecurityHeaders();
+      expect(headers["Content-Security-Policy-Report-Only"]).toContain("default-src 'self'");
+      expect(headers["Content-Security-Policy"]).toBeUndefined();
+    });
+  });
+});

+ 182 - 0
tests/security/security-headers-integration.test.ts

@@ -0,0 +1,182 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+import { applyCors } from "../../src/app/v1/_lib/cors";
+
+const mockValidateKey = vi.hoisted(() => vi.fn());
+const mockSetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
+const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
+const mockClearAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetTranslations = vi.hoisted(() => vi.fn());
+const mockGetEnvConfig = vi.hoisted(() => vi.fn());
+const mockLogger = vi.hoisted(() => ({
+  warn: vi.fn(),
+  error: vi.fn(),
+  info: vi.fn(),
+  debug: vi.fn(),
+}));
+
+vi.mock("@/lib/auth", () => ({
+  validateKey: mockValidateKey,
+  setAuthCookie: mockSetAuthCookie,
+  getSessionTokenMode: mockGetSessionTokenMode,
+  getLoginRedirectTarget: mockGetLoginRedirectTarget,
+  clearAuthCookie: mockClearAuthCookie,
+  getAuthCookie: mockGetAuthCookie,
+  withNoStoreHeaders: <T>(response: T): T => {
+    (response as Response).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
+    (response as Response).headers.set("Pragma", "no-cache");
+    return response;
+  },
+}));
+
+vi.mock("next-intl/server", () => ({
+  getTranslations: mockGetTranslations,
+}));
+
+vi.mock("@/lib/config/env.schema", () => ({
+  getEnvConfig: mockGetEnvConfig,
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: mockLogger,
+}));
+
+type LoginPostHandler = (request: NextRequest) => Promise<Response>;
+type LogoutPostHandler = (request: NextRequest) => Promise<Response>;
+
+function makeLoginRequest(body: unknown): NextRequest {
+  return new NextRequest("http://localhost/api/auth/login", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(body),
+  });
+}
+
+function makeLogoutRequest(): NextRequest {
+  return new NextRequest("http://localhost/api/auth/logout", {
+    method: "POST",
+  });
+}
+
+function expectSharedSecurityHeaders(response: Response) {
+  expect(response.headers.get("X-Frame-Options")).toBe("DENY");
+  expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
+  expect(response.headers.get("X-DNS-Prefetch-Control")).toBe("off");
+}
+
+const fakeSession = {
+  user: {
+    id: 1,
+    name: "Test User",
+    description: "desc",
+    role: "user" as const,
+  },
+  key: {
+    canLoginWebUi: true,
+  },
+};
+
+describe("security headers auth route integration", () => {
+  let loginPost: LoginPostHandler;
+  let logoutPost: LogoutPostHandler;
+
+  beforeEach(async () => {
+    vi.resetModules();
+    vi.clearAllMocks();
+
+    const t = vi.fn((messageKey: string) => `translated:${messageKey}`);
+    mockGetTranslations.mockResolvedValue(t);
+    mockValidateKey.mockResolvedValue(fakeSession);
+    mockSetAuthCookie.mockResolvedValue(undefined);
+    mockGetSessionTokenMode.mockReturnValue("legacy");
+    mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
+    mockClearAuthCookie.mockResolvedValue(undefined);
+    mockGetAuthCookie.mockResolvedValue(undefined);
+    mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
+
+    const loginRoute = await import("../../src/app/api/auth/login/route");
+    loginPost = loginRoute.POST;
+
+    const logoutRoute = await import("../../src/app/api/auth/logout/route");
+    logoutPost = logoutRoute.POST;
+  });
+
+  it("login success response includes security headers", async () => {
+    const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
+
+    expect(res.status).toBe(200);
+    expectSharedSecurityHeaders(res);
+    expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
+  });
+
+  it("login error response includes security headers", async () => {
+    const res = await loginPost(makeLoginRequest({}));
+
+    expect(res.status).toBe(400);
+    expectSharedSecurityHeaders(res);
+    expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
+  });
+
+  it("logout response includes security headers", async () => {
+    const res = await logoutPost(makeLogoutRequest());
+
+    expect(res.status).toBe(200);
+    expectSharedSecurityHeaders(res);
+    expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
+  });
+
+  it("CSP is applied in report-only mode by default", async () => {
+    const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
+
+    expect(res.headers.get("Content-Security-Policy-Report-Only")).toContain("default-src 'self'");
+    expect(res.headers.get("Content-Security-Policy")).toBeNull();
+  });
+
+  it("HSTS is present when ENABLE_SECURE_COOKIES=true", async () => {
+    mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true });
+
+    const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
+
+    expect(res.headers.get("Strict-Transport-Security")).toBe(
+      "max-age=31536000; includeSubDomains"
+    );
+  });
+
+  it("HSTS is absent when ENABLE_SECURE_COOKIES=false", async () => {
+    mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
+
+    const res = await logoutPost(makeLogoutRequest());
+
+    expect(res.headers.get("Strict-Transport-Security")).toBeNull();
+  });
+
+  it("X-Content-Type-Options is always nosniff", async () => {
+    mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true });
+    const secureRes = await loginPost(makeLoginRequest({ key: "valid-key" }));
+
+    mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
+    const errorRes = await loginPost(makeLoginRequest({}));
+    const logoutRes = await logoutPost(makeLogoutRequest());
+
+    expect(secureRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
+    expect(errorRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
+    expect(logoutRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
+  });
+
+  it("security headers remain compatible with existing CORS headers", async () => {
+    const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
+    const corsRes = applyCors(res, {
+      origin: "https://client.example.com",
+      requestHeaders: "content-type,x-api-key",
+    });
+
+    expect(corsRes.headers.get("Access-Control-Allow-Origin")).toBe("https://client.example.com");
+    expect(corsRes.headers.get("Access-Control-Allow-Headers")).toBe("content-type,x-api-key");
+    expect(corsRes.headers.get("Content-Security-Policy-Report-Only")).toContain(
+      "default-src 'self'"
+    );
+    expect(corsRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
+  });
+});

+ 1 - 0
tests/security/session-cookie-hardening.test.ts

@@ -178,6 +178,7 @@ describe("session cookie hardening", () => {
     beforeEach(async () => {
       vi.clearAllMocks();
       mockClearAuthCookie.mockResolvedValue(undefined);
+      mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
 
       const mod = await import("../../src/app/api/auth/logout/route");
       POST = mod.POST;

+ 2 - 0
tests/unit/auth/set-auth-cookie-options.test.ts

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
 const mockCookieSet = vi.hoisted(() => vi.fn());
 const mockCookies = vi.hoisted(() => vi.fn());
 const mockGetEnvConfig = vi.hoisted(() => vi.fn());
+const mockIsDevelopment = vi.hoisted(() => vi.fn(() => false));
 
 vi.mock("next/headers", () => ({
   cookies: mockCookies,
@@ -11,6 +12,7 @@ vi.mock("next/headers", () => ({
 
 vi.mock("@/lib/config/env.schema", () => ({
   getEnvConfig: mockGetEnvConfig,
+  isDevelopment: mockIsDevelopment,
 }));
 
 vi.mock("@/lib/config/config", () => ({ config: { auth: { adminToken: "test" } } }));