api-key-auth-cache.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. import type { Key } from "@/types/key";
  3. import type { User } from "@/types/user";
  4. const isDefinitelyNotPresent = vi.fn(() => false);
  5. const noteExistingKey = vi.fn();
  6. const cacheActiveKey = vi.fn(async () => {});
  7. const cacheAuthResult = vi.fn(async () => {});
  8. const cacheUser = vi.fn(async () => {});
  9. const getCachedActiveKey = vi.fn<(keyString: string) => Promise<Key | null>>();
  10. const getCachedUser = vi.fn<(userId: number) => Promise<User | null>>();
  11. const invalidateCachedKey = vi.fn(async () => {});
  12. const publishCacheInvalidation = vi.fn(async () => {});
  13. const dbSelect = vi.fn();
  14. const dbInsert = vi.fn();
  15. const dbUpdate = vi.fn();
  16. vi.mock("@/lib/security/api-key-vacuum-filter", () => ({
  17. apiKeyVacuumFilter: {
  18. isDefinitelyNotPresent,
  19. noteExistingKey,
  20. startBackgroundReload: vi.fn(),
  21. getStats: vi.fn(),
  22. },
  23. }));
  24. vi.mock("@/lib/security/api-key-auth-cache", () => ({
  25. cacheActiveKey,
  26. cacheAuthResult,
  27. cacheUser,
  28. getCachedActiveKey,
  29. getCachedUser,
  30. invalidateCachedKey,
  31. }));
  32. vi.mock("@/lib/redis/pubsub", () => ({
  33. CHANNEL_ERROR_RULES_UPDATED: "cch:cache:error_rules:updated",
  34. CHANNEL_REQUEST_FILTERS_UPDATED: "cch:cache:request_filters:updated",
  35. CHANNEL_SENSITIVE_WORDS_UPDATED: "cch:cache:sensitive_words:updated",
  36. CHANNEL_API_KEYS_UPDATED: "cch:cache:api_keys:updated",
  37. publishCacheInvalidation,
  38. subscribeCacheInvalidation: vi.fn(async () => null),
  39. }));
  40. vi.mock("@/drizzle/db", () => ({
  41. db: {
  42. select: dbSelect,
  43. insert: dbInsert,
  44. update: dbUpdate,
  45. },
  46. }));
  47. beforeEach(() => {
  48. vi.clearAllMocks();
  49. isDefinitelyNotPresent.mockReturnValue(false);
  50. getCachedActiveKey.mockResolvedValue(null);
  51. getCachedUser.mockResolvedValue(null);
  52. dbSelect.mockImplementation(() => {
  53. throw new Error("DB_ACCESS");
  54. });
  55. dbInsert.mockImplementation(() => {
  56. throw new Error("DB_ACCESS");
  57. });
  58. dbUpdate.mockImplementation(() => {
  59. throw new Error("DB_ACCESS");
  60. });
  61. });
  62. function buildKey(overrides?: Partial<Key>): Key {
  63. return {
  64. id: 1,
  65. userId: 10,
  66. name: "k1",
  67. key: "sk-test",
  68. isEnabled: true,
  69. expiresAt: undefined,
  70. canLoginWebUi: true,
  71. limit5hUsd: null,
  72. limitDailyUsd: null,
  73. dailyResetMode: "fixed",
  74. dailyResetTime: "00:00",
  75. limitWeeklyUsd: null,
  76. limitMonthlyUsd: null,
  77. limitTotalUsd: null,
  78. limitConcurrentSessions: 0,
  79. providerGroup: null,
  80. cacheTtlPreference: null,
  81. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  82. updatedAt: new Date("2026-01-02T00:00:00.000Z"),
  83. deletedAt: undefined,
  84. ...overrides,
  85. };
  86. }
  87. function buildUser(overrides?: Partial<User>): User {
  88. return {
  89. id: 10,
  90. name: "u1",
  91. description: "",
  92. role: "user",
  93. rpm: null,
  94. dailyQuota: null,
  95. providerGroup: null,
  96. tags: [],
  97. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  98. updatedAt: new Date("2026-01-02T00:00:00.000Z"),
  99. deletedAt: undefined,
  100. limit5hUsd: undefined,
  101. limitWeeklyUsd: undefined,
  102. limitMonthlyUsd: undefined,
  103. limitTotalUsd: null,
  104. limitConcurrentSessions: undefined,
  105. dailyResetMode: "fixed",
  106. dailyResetTime: "00:00",
  107. isEnabled: true,
  108. expiresAt: null,
  109. allowedClients: [],
  110. allowedModels: [],
  111. ...overrides,
  112. };
  113. }
  114. describe("API Key 鉴权缓存:VacuumFilter -> Redis -> DB", () => {
  115. test("findActiveKeyByKeyString:Vacuum Filter 误判缺失时,Redis 命中应纠正(避免误拒绝)", async () => {
  116. const cachedKey = buildKey({ key: "sk-cached-missing" });
  117. isDefinitelyNotPresent.mockReturnValueOnce(true);
  118. getCachedActiveKey.mockResolvedValueOnce(cachedKey);
  119. const { findActiveKeyByKeyString } = await import("@/repository/key");
  120. await expect(findActiveKeyByKeyString("sk-cached-missing")).resolves.toEqual(cachedKey);
  121. expect(noteExistingKey).toHaveBeenCalledWith("sk-cached-missing");
  122. expect(dbSelect).not.toHaveBeenCalled();
  123. });
  124. test("validateApiKeyAndGetUser:Vacuum Filter 误判缺失时,Redis key+user 命中应纠正(避免误拒绝)", async () => {
  125. const cachedKey = buildKey({ key: "sk-cached-missing", userId: 10 });
  126. const cachedUser = buildUser({ id: 10 });
  127. isDefinitelyNotPresent.mockReturnValueOnce(true);
  128. getCachedActiveKey.mockResolvedValueOnce(cachedKey);
  129. getCachedUser.mockResolvedValueOnce(cachedUser);
  130. const { validateApiKeyAndGetUser } = await import("@/repository/key");
  131. await expect(validateApiKeyAndGetUser("sk-cached-missing")).resolves.toEqual({
  132. user: cachedUser,
  133. key: cachedKey,
  134. });
  135. expect(noteExistingKey).toHaveBeenCalledWith("sk-cached-missing");
  136. expect(dbSelect).not.toHaveBeenCalled();
  137. });
  138. test("findActiveKeyByKeyString:Redis 命中时应避免打 DB", async () => {
  139. const cachedKey = buildKey({ key: "sk-cached" });
  140. getCachedActiveKey.mockResolvedValueOnce(cachedKey);
  141. dbSelect.mockImplementation(() => {
  142. throw new Error("DB_ACCESS");
  143. });
  144. const { findActiveKeyByKeyString } = await import("@/repository/key");
  145. await expect(findActiveKeyByKeyString("sk-cached")).resolves.toEqual(cachedKey);
  146. expect(getCachedActiveKey).toHaveBeenCalledWith("sk-cached");
  147. expect(dbSelect).not.toHaveBeenCalled();
  148. });
  149. test("findActiveKeyByKeyString:VF 判定不存在且 Redis 未命中时应短路返回 null", async () => {
  150. isDefinitelyNotPresent.mockReturnValueOnce(true);
  151. getCachedActiveKey.mockResolvedValueOnce(null);
  152. const { findActiveKeyByKeyString } = await import("@/repository/key");
  153. await expect(findActiveKeyByKeyString("sk-nonexistent")).resolves.toBeNull();
  154. expect(dbSelect).not.toHaveBeenCalled();
  155. });
  156. test("validateApiKeyAndGetUser:key+user Redis 命中时应避免打 DB", async () => {
  157. const cachedKey = buildKey({ key: "sk-cached", userId: 10 });
  158. const cachedUser = buildUser({ id: 10 });
  159. getCachedActiveKey.mockResolvedValueOnce(cachedKey);
  160. getCachedUser.mockResolvedValueOnce(cachedUser);
  161. dbSelect.mockImplementation(() => {
  162. throw new Error("DB_ACCESS");
  163. });
  164. const { validateApiKeyAndGetUser } = await import("@/repository/key");
  165. await expect(validateApiKeyAndGetUser("sk-cached")).resolves.toEqual({
  166. user: cachedUser,
  167. key: cachedKey,
  168. });
  169. expect(getCachedActiveKey).toHaveBeenCalledWith("sk-cached");
  170. expect(getCachedUser).toHaveBeenCalledWith(10);
  171. expect(dbSelect).not.toHaveBeenCalled();
  172. });
  173. test("validateApiKeyAndGetUser:key Redis 命中 + user miss 时应只查 user 并写回缓存", async () => {
  174. const cachedKey = buildKey({ key: "sk-cached", userId: 10 });
  175. getCachedActiveKey.mockResolvedValueOnce(cachedKey);
  176. getCachedUser.mockResolvedValueOnce(null);
  177. const userRow = {
  178. id: 10,
  179. name: "u1",
  180. description: "",
  181. role: "user",
  182. rpm: null,
  183. dailyQuota: null,
  184. providerGroup: null,
  185. tags: [],
  186. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  187. updatedAt: new Date("2026-01-02T00:00:00.000Z"),
  188. deletedAt: null,
  189. limit5hUsd: null,
  190. limitWeeklyUsd: null,
  191. limitMonthlyUsd: null,
  192. limitTotalUsd: null,
  193. limitConcurrentSessions: null,
  194. dailyResetMode: "fixed",
  195. dailyResetTime: "00:00",
  196. isEnabled: true,
  197. expiresAt: null,
  198. allowedClients: [],
  199. allowedModels: [],
  200. };
  201. dbSelect.mockReturnValueOnce({
  202. from: () => ({
  203. where: async () => [userRow],
  204. }),
  205. });
  206. const { validateApiKeyAndGetUser } = await import("@/repository/key");
  207. const result = await validateApiKeyAndGetUser("sk-cached");
  208. expect(result?.key).toEqual(cachedKey);
  209. expect(result?.user.id).toBe(10);
  210. expect(cacheUser).toHaveBeenCalledTimes(1);
  211. expect(cacheAuthResult).not.toHaveBeenCalled();
  212. });
  213. test("validateApiKeyAndGetUser:缓存未命中时应走 DB join 并写入 auth 缓存", async () => {
  214. getCachedActiveKey.mockResolvedValueOnce(null);
  215. const joinRow = {
  216. keyId: 1,
  217. keyUserId: 10,
  218. keyString: "sk-db",
  219. keyName: "k1",
  220. keyIsEnabled: true,
  221. keyExpiresAt: null,
  222. keyCanLoginWebUi: true,
  223. keyLimit5hUsd: null,
  224. keyLimitDailyUsd: null,
  225. keyDailyResetMode: "fixed",
  226. keyDailyResetTime: "00:00",
  227. keyLimitWeeklyUsd: null,
  228. keyLimitMonthlyUsd: null,
  229. keyLimitTotalUsd: null,
  230. keyLimitConcurrentSessions: 0,
  231. keyProviderGroup: null,
  232. keyCacheTtlPreference: null,
  233. keyCreatedAt: new Date("2026-01-01T00:00:00.000Z"),
  234. keyUpdatedAt: new Date("2026-01-02T00:00:00.000Z"),
  235. keyDeletedAt: null,
  236. userId: 10,
  237. userName: "u1",
  238. userDescription: "",
  239. userRole: "user",
  240. userRpm: null,
  241. userDailyQuota: null,
  242. userProviderGroup: null,
  243. userLimit5hUsd: null,
  244. userLimitWeeklyUsd: null,
  245. userLimitMonthlyUsd: null,
  246. userLimitTotalUsd: null,
  247. userLimitConcurrentSessions: null,
  248. userDailyResetMode: "fixed",
  249. userDailyResetTime: "00:00",
  250. userIsEnabled: true,
  251. userExpiresAt: null,
  252. userAllowedClients: [],
  253. userAllowedModels: [],
  254. userCreatedAt: new Date("2026-01-01T00:00:00.000Z"),
  255. userUpdatedAt: new Date("2026-01-02T00:00:00.000Z"),
  256. userDeletedAt: null,
  257. };
  258. dbSelect.mockReturnValueOnce({
  259. from: () => ({
  260. innerJoin: () => ({
  261. where: async () => [joinRow],
  262. }),
  263. }),
  264. });
  265. const { validateApiKeyAndGetUser } = await import("@/repository/key");
  266. const result = await validateApiKeyAndGetUser("sk-db");
  267. expect(result?.key.key).toBe("sk-db");
  268. expect(result?.user.id).toBe(10);
  269. expect(cacheAuthResult).toHaveBeenCalledTimes(1);
  270. });
  271. });
  272. describe("API Key 鉴权缓存:写入/失效点覆盖", () => {
  273. test("createKey:应广播 API key 集合变更(多实例触发 Vacuum Filter 重建)", async () => {
  274. const prevEnableRateLimit = process.env.ENABLE_RATE_LIMIT;
  275. const prevRedisUrl = process.env.REDIS_URL;
  276. process.env.ENABLE_RATE_LIMIT = "true";
  277. process.env.REDIS_URL = "redis://localhost:6379";
  278. const now = new Date("2026-01-02T00:00:00.000Z");
  279. const keyRow = {
  280. id: 1,
  281. userId: 10,
  282. key: "sk-created",
  283. name: "k1",
  284. isEnabled: true,
  285. expiresAt: null,
  286. canLoginWebUi: true,
  287. limit5hUsd: null,
  288. limitDailyUsd: null,
  289. dailyResetMode: "fixed",
  290. dailyResetTime: "00:00",
  291. limitWeeklyUsd: null,
  292. limitMonthlyUsd: null,
  293. limitTotalUsd: null,
  294. limitConcurrentSessions: 0,
  295. providerGroup: null,
  296. cacheTtlPreference: null,
  297. createdAt: now,
  298. updatedAt: now,
  299. deletedAt: null,
  300. };
  301. dbInsert.mockReturnValueOnce({
  302. values: () => ({
  303. returning: async () => [keyRow],
  304. }),
  305. });
  306. try {
  307. const { createKey } = await import("@/repository/key");
  308. const created = await createKey({ user_id: 10, name: "k1", key: "sk-created" });
  309. expect(created.key).toBe("sk-created");
  310. expect(publishCacheInvalidation).toHaveBeenCalledWith("cch:cache:api_keys:updated");
  311. } finally {
  312. process.env.ENABLE_RATE_LIMIT = prevEnableRateLimit;
  313. process.env.REDIS_URL = prevRedisUrl;
  314. }
  315. });
  316. test("updateKey:应触发 cacheActiveKey", async () => {
  317. const keyRow = {
  318. id: 1,
  319. userId: 10,
  320. key: "sk-update",
  321. name: "k1",
  322. isEnabled: true,
  323. expiresAt: null,
  324. canLoginWebUi: true,
  325. limit5hUsd: null,
  326. limitDailyUsd: null,
  327. dailyResetMode: "fixed",
  328. dailyResetTime: "00:00",
  329. limitWeeklyUsd: null,
  330. limitMonthlyUsd: null,
  331. limitTotalUsd: null,
  332. limitConcurrentSessions: 0,
  333. providerGroup: null,
  334. cacheTtlPreference: null,
  335. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  336. updatedAt: new Date("2026-01-02T00:00:00.000Z"),
  337. deletedAt: null,
  338. };
  339. dbUpdate.mockReturnValueOnce({
  340. set: () => ({
  341. where: () => ({
  342. returning: async () => [keyRow],
  343. }),
  344. }),
  345. });
  346. const { updateKey } = await import("@/repository/key");
  347. const updated = await updateKey(1, { name: "k2" });
  348. expect(updated?.key).toBe("sk-update");
  349. expect(cacheActiveKey).toHaveBeenCalledTimes(1);
  350. });
  351. test("deleteKey:删除成功时应触发 invalidateCachedKey", async () => {
  352. dbUpdate.mockReturnValueOnce({
  353. set: () => ({
  354. where: () => ({
  355. returning: async () => [{ id: 1, key: "sk-deleted" }],
  356. }),
  357. }),
  358. });
  359. const { deleteKey } = await import("@/repository/key");
  360. await expect(deleteKey(1)).resolves.toBe(true);
  361. expect(invalidateCachedKey).toHaveBeenCalledWith("sk-deleted");
  362. });
  363. });