auth-dual-read.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import crypto from "node:crypto";
  2. import { beforeEach, describe, expect, it, vi } from "vitest";
  3. import type { Key } from "@/types/key";
  4. import type { User } from "@/types/user";
  5. const mockCookies = vi.hoisted(() => vi.fn());
  6. const mockHeaders = vi.hoisted(() => vi.fn());
  7. const mockGetEnvConfig = vi.hoisted(() => vi.fn());
  8. const mockValidateApiKeyAndGetUser = vi.hoisted(() => vi.fn());
  9. const mockFindKeyList = vi.hoisted(() => vi.fn());
  10. const mockReadSession = vi.hoisted(() => vi.fn());
  11. const mockCookieStore = vi.hoisted(() => ({
  12. get: vi.fn(),
  13. set: vi.fn(),
  14. delete: vi.fn(),
  15. }));
  16. const mockHeadersStore = vi.hoisted(() => ({
  17. get: vi.fn(),
  18. }));
  19. const loggerMock = vi.hoisted(() => ({
  20. warn: vi.fn(),
  21. error: vi.fn(),
  22. info: vi.fn(),
  23. debug: vi.fn(),
  24. trace: vi.fn(),
  25. }));
  26. vi.mock("next/headers", () => ({
  27. cookies: mockCookies,
  28. headers: mockHeaders,
  29. }));
  30. vi.mock("@/lib/config/env.schema", () => ({
  31. getEnvConfig: mockGetEnvConfig,
  32. }));
  33. vi.mock("@/repository/key", () => ({
  34. validateApiKeyAndGetUser: mockValidateApiKeyAndGetUser,
  35. findKeyList: mockFindKeyList,
  36. }));
  37. vi.mock("@/lib/auth-session-store/redis-session-store", () => ({
  38. RedisSessionStore: class {
  39. read = mockReadSession;
  40. create = vi.fn();
  41. revoke = vi.fn();
  42. rotate = vi.fn();
  43. },
  44. }));
  45. vi.mock("@/lib/logger", () => ({
  46. logger: loggerMock,
  47. }));
  48. vi.mock("@/lib/config/config", () => ({
  49. config: { auth: { adminToken: "" } },
  50. }));
  51. function setSessionMode(mode: "legacy" | "dual" | "opaque") {
  52. mockGetEnvConfig.mockReturnValue({
  53. SESSION_TOKEN_MODE: mode,
  54. ENABLE_SECURE_COOKIES: false,
  55. });
  56. }
  57. function setAuthToken(token?: string) {
  58. mockCookieStore.get.mockReturnValue(token ? { value: token } : undefined);
  59. }
  60. function toFingerprint(keyString: string): string {
  61. return `sha256:${crypto.createHash("sha256").update(keyString, "utf8").digest("hex")}`;
  62. }
  63. function buildUser(id: number): User {
  64. const now = new Date("2026-02-18T10:00:00.000Z");
  65. return {
  66. id,
  67. name: `user-${id}`,
  68. description: "test user",
  69. role: "user",
  70. rpm: 100,
  71. dailyQuota: 100,
  72. providerGroup: null,
  73. tags: [],
  74. createdAt: now,
  75. updatedAt: now,
  76. limit5hUsd: 0,
  77. limitWeeklyUsd: 0,
  78. limitMonthlyUsd: 0,
  79. limitTotalUsd: null,
  80. limitConcurrentSessions: 0,
  81. dailyResetMode: "fixed",
  82. dailyResetTime: "00:00",
  83. isEnabled: true,
  84. expiresAt: null,
  85. allowedClients: [],
  86. allowedModels: [],
  87. };
  88. }
  89. function buildKey(id: number, userId: number, keyString: string, canLoginWebUi = true): Key {
  90. const now = new Date("2026-02-18T10:00:00.000Z");
  91. return {
  92. id,
  93. userId,
  94. name: `key-${id}`,
  95. key: keyString,
  96. isEnabled: true,
  97. canLoginWebUi,
  98. limit5hUsd: null,
  99. limitDailyUsd: null,
  100. dailyResetMode: "fixed",
  101. dailyResetTime: "00:00",
  102. limitWeeklyUsd: null,
  103. limitMonthlyUsd: null,
  104. limitTotalUsd: null,
  105. limitConcurrentSessions: 0,
  106. providerGroup: null,
  107. cacheTtlPreference: null,
  108. createdAt: now,
  109. updatedAt: now,
  110. };
  111. }
  112. function buildAuthResult(keyString: string, userId = 1) {
  113. return {
  114. user: buildUser(userId),
  115. key: buildKey(userId, userId, keyString),
  116. };
  117. }
  118. describe("auth dual-read session resolver", () => {
  119. beforeEach(() => {
  120. vi.resetModules();
  121. vi.clearAllMocks();
  122. mockCookies.mockResolvedValue(mockCookieStore);
  123. mockHeaders.mockResolvedValue(mockHeadersStore);
  124. mockHeadersStore.get.mockReturnValue(null);
  125. mockCookieStore.get.mockReturnValue(undefined);
  126. setSessionMode("legacy");
  127. mockReadSession.mockResolvedValue(null);
  128. mockFindKeyList.mockResolvedValue([]);
  129. mockValidateApiKeyAndGetUser.mockResolvedValue(null);
  130. });
  131. it("legacy mode keeps legacy key validation path unchanged", async () => {
  132. setSessionMode("legacy");
  133. setAuthToken("sk-legacy");
  134. const authResult = buildAuthResult("sk-legacy", 11);
  135. mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
  136. const { getSessionWithDualRead } = await import("@/lib/auth");
  137. const session = await getSessionWithDualRead();
  138. expect(session).toEqual(authResult);
  139. expect(mockReadSession).not.toHaveBeenCalled();
  140. expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1);
  141. expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-legacy");
  142. });
  143. it("dual mode tries opaque read first and then falls back to legacy cookie", async () => {
  144. setSessionMode("dual");
  145. setAuthToken("sk-dual");
  146. const authResult = buildAuthResult("sk-dual", 12);
  147. mockReadSession.mockResolvedValue(null);
  148. mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
  149. const { getSessionWithDualRead } = await import("@/lib/auth");
  150. const session = await getSessionWithDualRead();
  151. expect(session).toEqual(authResult);
  152. expect(mockReadSession).toHaveBeenCalledTimes(1);
  153. expect(mockReadSession).toHaveBeenCalledWith("sk-dual");
  154. expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-dual");
  155. expect(mockReadSession.mock.invocationCallOrder[0]).toBeLessThan(
  156. mockValidateApiKeyAndGetUser.mock.invocationCallOrder[0]
  157. );
  158. });
  159. it("opaque mode only reads opaque session and never falls back to legacy", async () => {
  160. setSessionMode("opaque");
  161. setAuthToken("sk-legacy-in-opaque");
  162. mockReadSession.mockResolvedValue(null);
  163. mockValidateApiKeyAndGetUser.mockResolvedValue(buildAuthResult("sk-legacy-in-opaque", 13));
  164. const { getSessionWithDualRead } = await import("@/lib/auth");
  165. const session = await getSessionWithDualRead();
  166. expect(session).toBeNull();
  167. expect(mockReadSession).toHaveBeenCalledTimes(1);
  168. expect(mockReadSession).toHaveBeenCalledWith("sk-legacy-in-opaque");
  169. expect(mockValidateApiKeyAndGetUser).not.toHaveBeenCalled();
  170. });
  171. it("returns a valid auth session when opaque session is found", async () => {
  172. setSessionMode("dual");
  173. setAuthToken("sid_opaque_found");
  174. const keyString = "sk-opaque-source";
  175. const authResult = buildAuthResult(keyString, 21);
  176. mockReadSession.mockResolvedValue({
  177. sessionId: "sid_opaque_found",
  178. keyFingerprint: toFingerprint(keyString),
  179. userId: 21,
  180. userRole: "user",
  181. createdAt: Date.now(),
  182. expiresAt: Date.now() + 3_600_000,
  183. });
  184. mockFindKeyList.mockResolvedValue([
  185. buildKey(1, 21, "sk-not-match"),
  186. buildKey(2, 21, keyString),
  187. ]);
  188. mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
  189. const { getSessionWithDualRead } = await import("@/lib/auth");
  190. const session = await getSessionWithDualRead({ allowReadOnlyAccess: true });
  191. expect(session).toEqual(authResult);
  192. expect(mockReadSession).toHaveBeenCalledWith("sid_opaque_found");
  193. expect(mockFindKeyList).toHaveBeenCalledWith(21);
  194. expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1);
  195. expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith(keyString);
  196. });
  197. it("validateSession falls back to legacy path when opaque session is missing in dual mode", async () => {
  198. setSessionMode("dual");
  199. setAuthToken("sk-dual-fallback");
  200. const authResult = buildAuthResult("sk-dual-fallback", 22);
  201. mockReadSession.mockResolvedValue(null);
  202. mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
  203. const { validateSession } = await import("@/lib/auth");
  204. const session = await validateSession();
  205. expect(session).toEqual(authResult);
  206. expect(mockReadSession).toHaveBeenCalledTimes(1);
  207. expect(mockReadSession).toHaveBeenCalledWith("sk-dual-fallback");
  208. expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1);
  209. expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-dual-fallback");
  210. });
  211. it("dual mode gracefully falls back to legacy when opaque session store read fails", async () => {
  212. setSessionMode("dual");
  213. setAuthToken("sk-store-error");
  214. const authResult = buildAuthResult("sk-store-error", 23);
  215. mockReadSession.mockRejectedValue(new Error("redis unavailable"));
  216. mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
  217. const { getSessionWithDualRead } = await import("@/lib/auth");
  218. const session = await getSessionWithDualRead();
  219. expect(session).toEqual(authResult);
  220. expect(mockReadSession).toHaveBeenCalledTimes(1);
  221. expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1);
  222. expect(loggerMock.warn).toHaveBeenCalledWith(
  223. "Opaque session read failed",
  224. expect.objectContaining({
  225. error: expect.stringContaining("redis unavailable"),
  226. })
  227. );
  228. });
  229. });