session-cookie-hardening.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { NextRequest, NextResponse } from "next/server";
  3. const mockValidateKey = vi.hoisted(() => vi.fn());
  4. const mockSetAuthCookie = vi.hoisted(() => vi.fn());
  5. const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
  6. const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
  7. const mockGetTranslations = vi.hoisted(() => vi.fn());
  8. const mockLogger = vi.hoisted(() => ({
  9. warn: vi.fn(),
  10. error: vi.fn(),
  11. info: vi.fn(),
  12. debug: vi.fn(),
  13. }));
  14. const mockCookieSet = vi.hoisted(() => vi.fn());
  15. const mockCookies = vi.hoisted(() => vi.fn());
  16. const mockGetEnvConfig = vi.hoisted(() => vi.fn());
  17. const mockClearAuthCookie = vi.hoisted(() => vi.fn());
  18. const realWithNoStoreHeaders = vi.hoisted(() => {
  19. return <T extends InstanceType<typeof NextResponse>>(response: T): T => {
  20. response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  21. response.headers.set("Pragma", "no-cache");
  22. return response;
  23. };
  24. });
  25. vi.mock("@/lib/auth", () => ({
  26. validateKey: mockValidateKey,
  27. setAuthCookie: mockSetAuthCookie,
  28. getSessionTokenMode: mockGetSessionTokenMode,
  29. clearAuthCookie: mockClearAuthCookie,
  30. getLoginRedirectTarget: mockGetLoginRedirectTarget,
  31. toKeyFingerprint: vi.fn().mockResolvedValue("sha256:mock"),
  32. withNoStoreHeaders: realWithNoStoreHeaders,
  33. }));
  34. vi.mock("next-intl/server", () => ({
  35. getTranslations: mockGetTranslations,
  36. }));
  37. vi.mock("@/lib/logger", () => ({
  38. logger: mockLogger,
  39. }));
  40. vi.mock("@/lib/config/env.schema", () => ({
  41. getEnvConfig: mockGetEnvConfig,
  42. }));
  43. vi.mock("@/lib/security/auth-response-headers", () => ({
  44. withAuthResponseHeaders: realWithNoStoreHeaders,
  45. }));
  46. vi.mock("@/lib/config/config", () => ({ config: { auth: { adminToken: "test" } } }));
  47. vi.mock("@/repository/key", () => ({ validateApiKeyAndGetUser: vi.fn() }));
  48. vi.mock("next/headers", () => ({
  49. cookies: mockCookies,
  50. headers: vi.fn().mockResolvedValue(new Headers()),
  51. }));
  52. const EXPECTED_CACHE_CONTROL = "no-store, no-cache, must-revalidate";
  53. const EXPECTED_PRAGMA = "no-cache";
  54. function makeLoginRequest(body: unknown): NextRequest {
  55. return new NextRequest("http://localhost/api/auth/login", {
  56. method: "POST",
  57. headers: { "Content-Type": "application/json" },
  58. body: JSON.stringify(body),
  59. });
  60. }
  61. function makeLogoutRequest(): NextRequest {
  62. return new NextRequest("http://localhost/api/auth/logout", {
  63. method: "POST",
  64. });
  65. }
  66. const fakeSession = {
  67. user: { id: 1, name: "Test User", description: "desc", role: "user" as const },
  68. key: { canLoginWebUi: true },
  69. };
  70. describe("session cookie hardening", () => {
  71. describe("withNoStoreHeaders utility", () => {
  72. it("sets Cache-Control header", () => {
  73. const res = NextResponse.json({ ok: true });
  74. const result = realWithNoStoreHeaders(res);
  75. expect(result.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
  76. });
  77. it("sets Pragma header", () => {
  78. const res = NextResponse.json({ ok: true });
  79. const result = realWithNoStoreHeaders(res);
  80. expect(result.headers.get("Pragma")).toBe(EXPECTED_PRAGMA);
  81. });
  82. it("returns the same response object", () => {
  83. const res = NextResponse.json({ ok: true });
  84. const result = realWithNoStoreHeaders(res);
  85. expect(result).toBe(res);
  86. });
  87. });
  88. describe("login route no-store headers", () => {
  89. let POST: (request: NextRequest) => Promise<Response>;
  90. beforeEach(async () => {
  91. vi.clearAllMocks();
  92. const mockT = vi.fn((key: string) => `translated:${key}`);
  93. mockGetTranslations.mockResolvedValue(mockT);
  94. mockSetAuthCookie.mockResolvedValue(undefined);
  95. mockGetSessionTokenMode.mockReturnValue("legacy");
  96. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
  97. const mod = await import("@/app/api/auth/login/route");
  98. POST = mod.POST;
  99. });
  100. it("success response includes Cache-Control: no-store", async () => {
  101. mockValidateKey.mockResolvedValue(fakeSession);
  102. mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
  103. const res = await POST(makeLoginRequest({ key: "valid" }));
  104. expect(res.status).toBe(200);
  105. expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
  106. });
  107. it("success response includes Pragma: no-cache", async () => {
  108. mockValidateKey.mockResolvedValue(fakeSession);
  109. mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
  110. const res = await POST(makeLoginRequest({ key: "valid" }));
  111. expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA);
  112. });
  113. it("400 error response includes Cache-Control: no-store", async () => {
  114. const res = await POST(makeLoginRequest({}));
  115. expect(res.status).toBe(400);
  116. expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
  117. });
  118. it("400 error response includes Pragma: no-cache", async () => {
  119. const res = await POST(makeLoginRequest({}));
  120. expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA);
  121. });
  122. it("401 error response includes Cache-Control: no-store", async () => {
  123. mockValidateKey.mockResolvedValue(null);
  124. const res = await POST(makeLoginRequest({ key: "bad" }));
  125. expect(res.status).toBe(401);
  126. expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
  127. });
  128. it("401 error response includes Pragma: no-cache", async () => {
  129. mockValidateKey.mockResolvedValue(null);
  130. const res = await POST(makeLoginRequest({ key: "bad" }));
  131. expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA);
  132. });
  133. it("500 error response includes no-store headers", async () => {
  134. mockValidateKey.mockRejectedValue(new Error("db down"));
  135. const res = await POST(makeLoginRequest({ key: "any" }));
  136. expect(res.status).toBe(500);
  137. expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
  138. expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA);
  139. });
  140. });
  141. describe("logout route no-store headers", () => {
  142. let POST: (request: NextRequest) => Promise<Response>;
  143. beforeEach(async () => {
  144. vi.clearAllMocks();
  145. mockClearAuthCookie.mockResolvedValue(undefined);
  146. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
  147. const mod = await import("@/app/api/auth/logout/route");
  148. POST = mod.POST;
  149. });
  150. it("response includes Cache-Control: no-store", async () => {
  151. const res = await POST(makeLogoutRequest());
  152. expect(res.status).toBe(200);
  153. expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
  154. });
  155. it("response includes Pragma: no-cache", async () => {
  156. const res = await POST(makeLogoutRequest());
  157. expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA);
  158. });
  159. });
  160. });