session-login-integration.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { NextRequest } 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 mockToKeyFingerprint = vi.hoisted(() => vi.fn());
  8. const mockGetTranslations = vi.hoisted(() => vi.fn());
  9. const mockCreateSession = vi.hoisted(() => vi.fn());
  10. const mockGetEnvConfig = vi.hoisted(() => vi.fn());
  11. const mockLogger = vi.hoisted(() => ({
  12. warn: vi.fn(),
  13. error: vi.fn(),
  14. info: vi.fn(),
  15. debug: vi.fn(),
  16. }));
  17. const realWithNoStoreHeaders = vi.hoisted(() => {
  18. return (response: any) => {
  19. response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  20. response.headers.set("Pragma", "no-cache");
  21. return response;
  22. };
  23. });
  24. vi.mock("@/lib/auth", () => ({
  25. validateKey: mockValidateKey,
  26. setAuthCookie: mockSetAuthCookie,
  27. getSessionTokenMode: mockGetSessionTokenMode,
  28. getLoginRedirectTarget: mockGetLoginRedirectTarget,
  29. toKeyFingerprint: mockToKeyFingerprint,
  30. withNoStoreHeaders: realWithNoStoreHeaders,
  31. }));
  32. vi.mock("@/lib/auth-session-store/redis-session-store", () => ({
  33. RedisSessionStore: class {
  34. create = mockCreateSession;
  35. },
  36. }));
  37. vi.mock("next-intl/server", () => ({
  38. getTranslations: mockGetTranslations,
  39. }));
  40. vi.mock("@/lib/logger", () => ({
  41. logger: mockLogger,
  42. }));
  43. vi.mock("@/lib/config/env.schema", () => ({
  44. getEnvConfig: mockGetEnvConfig,
  45. }));
  46. vi.mock("@/lib/security/auth-response-headers", () => ({
  47. withAuthResponseHeaders: realWithNoStoreHeaders,
  48. }));
  49. function makeRequest(body: unknown): NextRequest {
  50. return new NextRequest("http://localhost/api/auth/login", {
  51. method: "POST",
  52. headers: { "Content-Type": "application/json" },
  53. body: JSON.stringify(body),
  54. });
  55. }
  56. const dashboardSession = {
  57. user: {
  58. id: 1,
  59. name: "Test User",
  60. description: "desc",
  61. role: "user" as const,
  62. },
  63. key: { canLoginWebUi: true },
  64. };
  65. const readonlySession = {
  66. user: {
  67. id: 2,
  68. name: "Readonly User",
  69. description: "readonly",
  70. role: "user" as const,
  71. },
  72. key: { canLoginWebUi: false },
  73. };
  74. describe("POST /api/auth/login session token mode integration", () => {
  75. let POST: (request: NextRequest) => Promise<Response>;
  76. beforeEach(async () => {
  77. vi.clearAllMocks();
  78. const mockT = vi.fn((key: string) => `translated:${key}`);
  79. mockGetTranslations.mockResolvedValue(mockT);
  80. mockValidateKey.mockResolvedValue(dashboardSession);
  81. mockSetAuthCookie.mockResolvedValue(undefined);
  82. mockGetSessionTokenMode.mockReturnValue("legacy");
  83. mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
  84. mockToKeyFingerprint.mockResolvedValue(
  85. "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
  86. );
  87. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
  88. mockCreateSession.mockResolvedValue({
  89. sessionId: "sid_opaque_session_123",
  90. keyFingerprint: "sha256:abcdef",
  91. userId: 1,
  92. userRole: "user",
  93. createdAt: 100,
  94. expiresAt: 200,
  95. });
  96. const mod = await import("../../src/app/api/auth/login/route");
  97. POST = mod.POST;
  98. });
  99. it("legacy mode keeps raw key cookie and does not create opaque session", async () => {
  100. mockGetSessionTokenMode.mockReturnValue("legacy");
  101. const res = await POST(makeRequest({ key: "legacy-key" }));
  102. const json = await res.json();
  103. expect(res.status).toBe(200);
  104. expect(mockSetAuthCookie).toHaveBeenCalledTimes(1);
  105. expect(mockSetAuthCookie).toHaveBeenCalledWith("legacy-key");
  106. expect(mockCreateSession).not.toHaveBeenCalled();
  107. expect(json.redirectTo).toBe("/dashboard");
  108. expect(json.loginType).toBe("dashboard_user");
  109. });
  110. it("dual mode sets legacy cookie and creates opaque session in store", async () => {
  111. mockGetSessionTokenMode.mockReturnValue("dual");
  112. const res = await POST(makeRequest({ key: "dual-key" }));
  113. const json = await res.json();
  114. expect(res.status).toBe(200);
  115. expect(mockSetAuthCookie).toHaveBeenCalledTimes(1);
  116. expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-key");
  117. expect(mockCreateSession).toHaveBeenCalledTimes(1);
  118. expect(mockCreateSession).toHaveBeenCalledWith(
  119. expect.objectContaining({
  120. userId: 1,
  121. userRole: "user",
  122. keyFingerprint: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
  123. })
  124. );
  125. expect(json.redirectTo).toBe("/dashboard");
  126. expect(json.loginType).toBe("dashboard_user");
  127. });
  128. it("opaque mode writes sessionId cookie instead of raw key", async () => {
  129. mockGetSessionTokenMode.mockReturnValue("opaque");
  130. mockCreateSession.mockResolvedValue({
  131. sessionId: "sid_opaque_session_cookie",
  132. keyFingerprint: "sha256:abcdef",
  133. userId: 1,
  134. userRole: "user",
  135. createdAt: 100,
  136. expiresAt: 200,
  137. });
  138. const res = await POST(makeRequest({ key: "opaque-key" }));
  139. const json = await res.json();
  140. expect(res.status).toBe(200);
  141. expect(mockCreateSession).toHaveBeenCalledTimes(1);
  142. expect(mockSetAuthCookie).toHaveBeenCalledTimes(1);
  143. expect(mockSetAuthCookie).toHaveBeenCalledWith("sid_opaque_session_cookie");
  144. expect(mockSetAuthCookie).not.toHaveBeenCalledWith("opaque-key");
  145. expect(json.redirectTo).toBe("/dashboard");
  146. expect(json.loginType).toBe("dashboard_user");
  147. });
  148. it("dual mode remains successful when opaque session creation fails", async () => {
  149. mockGetSessionTokenMode.mockReturnValue("dual");
  150. mockCreateSession.mockRejectedValue(new Error("redis unavailable"));
  151. const res = await POST(makeRequest({ key: "dual-fallback-key" }));
  152. const json = await res.json();
  153. expect(res.status).toBe(200);
  154. expect(json.ok).toBe(true);
  155. expect(mockSetAuthCookie).toHaveBeenCalledTimes(1);
  156. expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-fallback-key");
  157. expect(mockCreateSession).toHaveBeenCalledTimes(1);
  158. expect(mockLogger.warn).toHaveBeenCalledWith(
  159. "Failed to create opaque session in dual mode",
  160. expect.objectContaining({
  161. error: expect.stringContaining("redis unavailable"),
  162. })
  163. );
  164. });
  165. it("all modes preserve readonly redirect semantics", async () => {
  166. mockValidateKey.mockResolvedValue(readonlySession);
  167. mockGetLoginRedirectTarget.mockReturnValue("/my-usage");
  168. const modes = ["legacy", "dual", "opaque"] as const;
  169. for (const mode of modes) {
  170. vi.clearAllMocks();
  171. mockGetSessionTokenMode.mockReturnValue(mode);
  172. mockValidateKey.mockResolvedValue(readonlySession);
  173. mockGetLoginRedirectTarget.mockReturnValue("/my-usage");
  174. mockSetAuthCookie.mockResolvedValue(undefined);
  175. mockCreateSession.mockResolvedValue({
  176. sessionId: `sid_${mode}_session`,
  177. keyFingerprint: "sha256:abcdef",
  178. userId: 2,
  179. userRole: "user",
  180. createdAt: 100,
  181. expiresAt: 200,
  182. });
  183. const res = await POST(makeRequest({ key: `${mode}-readonly-key` }));
  184. const json = await res.json();
  185. expect(res.status).toBe(200);
  186. expect(json.redirectTo).toBe("/my-usage");
  187. expect(json.loginType).toBe("readonly_user");
  188. if (mode === "legacy") {
  189. expect(mockCreateSession).not.toHaveBeenCalled();
  190. expect(mockSetAuthCookie).toHaveBeenCalledWith("legacy-readonly-key");
  191. }
  192. if (mode === "dual") {
  193. expect(mockCreateSession).toHaveBeenCalledTimes(1);
  194. expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-readonly-key");
  195. }
  196. if (mode === "opaque") {
  197. expect(mockCreateSession).toHaveBeenCalledTimes(1);
  198. expect(mockSetAuthCookie).toHaveBeenCalledWith("sid_opaque_session");
  199. }
  200. }
  201. });
  202. });