api-key-auth-cache-redis-key.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
  2. import { createHash, webcrypto } from "node:crypto";
  3. import type { Key } from "@/types/key";
  4. import type { User } from "@/types/user";
  5. type RedisPipelineLike = {
  6. setex(key: string, ttlSeconds: number, value: string): RedisPipelineLike;
  7. del(key: string): RedisPipelineLike;
  8. exec(): Promise<unknown>;
  9. };
  10. type RedisLike = {
  11. get(key: string): Promise<string | null>;
  12. setex(key: string, ttlSeconds: number, value: string): Promise<unknown>;
  13. del(key: string): Promise<number>;
  14. pipeline(): RedisPipelineLike;
  15. };
  16. type PipelineOp =
  17. | { kind: "setex"; key: string; ttlSeconds: number; value: string }
  18. | { kind: "del"; key: string };
  19. class FakeRedisPipeline implements RedisPipelineLike {
  20. readonly ops: PipelineOp[] = [];
  21. readonly exec = vi.fn(async () => {
  22. for (const op of this.ops) {
  23. if (op.kind === "setex") {
  24. this.parent.store.set(op.key, op.value);
  25. } else {
  26. this.parent.store.delete(op.key);
  27. }
  28. }
  29. return [];
  30. });
  31. constructor(private readonly parent: FakeRedis) {}
  32. setex(key: string, ttlSeconds: number, value: string): RedisPipelineLike {
  33. this.ops.push({ kind: "setex", key, ttlSeconds, value });
  34. return this;
  35. }
  36. del(key: string): RedisPipelineLike {
  37. this.ops.push({ kind: "del", key });
  38. return this;
  39. }
  40. }
  41. class FakeRedis implements RedisLike {
  42. readonly store = new Map<string, string>();
  43. readonly get = vi.fn(async (key: string) => this.store.get(key) ?? null);
  44. readonly setex = vi.fn(async (key: string, _ttlSeconds: number, value: string) => {
  45. this.store.set(key, value);
  46. return "OK";
  47. });
  48. readonly del = vi.fn(async (key: string) => (this.store.delete(key) ? 1 : 0));
  49. readonly pipeline = vi.fn(() => {
  50. const pipeline = new FakeRedisPipeline(this);
  51. this.pipelines.push(pipeline);
  52. return pipeline;
  53. });
  54. readonly pipelines: FakeRedisPipeline[] = [];
  55. }
  56. let currentRedis: FakeRedis | null = null;
  57. const getRedisClient = vi.fn(() => currentRedis);
  58. vi.mock("@/lib/redis/client", () => ({
  59. getRedisClient,
  60. }));
  61. function sha256HexNode(value: string): string {
  62. return createHash("sha256").update(value).digest("hex");
  63. }
  64. function buildKey(overrides?: Partial<Key>): Key {
  65. return {
  66. id: 1,
  67. userId: 10,
  68. name: "k1",
  69. key: "sk-secret",
  70. isEnabled: true,
  71. expiresAt: undefined,
  72. canLoginWebUi: true,
  73. limit5hUsd: null,
  74. limitDailyUsd: null,
  75. dailyResetMode: "fixed",
  76. dailyResetTime: "00:00",
  77. limitWeeklyUsd: null,
  78. limitMonthlyUsd: null,
  79. limitTotalUsd: null,
  80. limitConcurrentSessions: 0,
  81. providerGroup: null,
  82. cacheTtlPreference: null,
  83. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  84. updatedAt: new Date("2026-01-02T00:00:00.000Z"),
  85. deletedAt: undefined,
  86. ...overrides,
  87. };
  88. }
  89. function buildUser(overrides?: Partial<User>): User {
  90. return {
  91. id: 10,
  92. name: "u1",
  93. description: "",
  94. role: "user",
  95. rpm: null,
  96. dailyQuota: null,
  97. providerGroup: null,
  98. tags: [],
  99. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  100. updatedAt: new Date("2026-01-02T00:00:00.000Z"),
  101. deletedAt: undefined,
  102. dailyResetMode: "fixed",
  103. dailyResetTime: "00:00",
  104. isEnabled: true,
  105. expiresAt: null,
  106. allowedClients: [],
  107. allowedModels: [],
  108. ...overrides,
  109. };
  110. }
  111. function setEnv(values: Record<string, string | undefined>): void {
  112. for (const [key, value] of Object.entries(values)) {
  113. if (value === undefined) {
  114. // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
  115. delete process.env[key];
  116. } else {
  117. process.env[key] = value;
  118. }
  119. }
  120. }
  121. describe("ApiKeyAuthCache:Redis key(哈希/命名/TTL/失效)", () => {
  122. const originalEnv: Record<string, string | undefined> = {};
  123. beforeEach(() => {
  124. vi.resetModules();
  125. vi.clearAllMocks();
  126. currentRedis = new FakeRedis();
  127. // 记录并覆盖本文件会改动的环境变量(避免泄漏到其它用例)
  128. for (const k of [
  129. "CI",
  130. "NEXT_PHASE",
  131. "NEXT_RUNTIME",
  132. "ENABLE_RATE_LIMIT",
  133. "REDIS_URL",
  134. "ENABLE_API_KEY_REDIS_CACHE",
  135. "API_KEY_AUTH_CACHE_TTL_SECONDS",
  136. ]) {
  137. originalEnv[k] = process.env[k];
  138. }
  139. setEnv({
  140. CI: "false",
  141. NEXT_PHASE: "",
  142. NEXT_RUNTIME: "nodejs",
  143. ENABLE_RATE_LIMIT: "true",
  144. REDIS_URL: "redis://localhost:6379",
  145. ENABLE_API_KEY_REDIS_CACHE: "true",
  146. API_KEY_AUTH_CACHE_TTL_SECONDS: "60",
  147. });
  148. // 确保测试环境一定有 WebCrypto subtle(不依赖 Node 版本/运行模式)
  149. vi.stubGlobal("crypto", webcrypto as unknown as Crypto);
  150. });
  151. afterEach(() => {
  152. vi.useRealTimers();
  153. vi.unstubAllGlobals();
  154. setEnv(originalEnv);
  155. currentRedis = null;
  156. });
  157. test("cacheActiveKey:应使用 SHA-256(keyString) 作为 Redis key,且不泄漏明文 key", async () => {
  158. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  159. const key = buildKey({ key: "sk-secret" });
  160. await cacheActiveKey(key);
  161. const expectedRedisKey = `api_key_auth:v1:key:${sha256HexNode("sk-secret")}`;
  162. expect(getRedisClient).toHaveBeenCalled();
  163. expect(currentRedis?.setex).toHaveBeenCalledTimes(1);
  164. const [redisKey, ttlSeconds, payload] = currentRedis!.setex.mock.calls[0];
  165. expect(redisKey).toBe(expectedRedisKey);
  166. expect(redisKey).not.toContain("sk-secret");
  167. expect(ttlSeconds).toBe(60);
  168. expect(typeof payload).toBe("string");
  169. expect(payload).not.toContain("sk-secret");
  170. const parsed = JSON.parse(payload) as { v: number; key: Record<string, unknown> };
  171. expect(parsed.v).toBe(1);
  172. // payload.key 不应包含明文 key 字段
  173. expect(Object.hasOwn(parsed.key, "key")).toBe(false);
  174. });
  175. test("cacheActiveKey + getCachedActiveKey:应可回读并水合 Date 字段", async () => {
  176. const { cacheActiveKey, getCachedActiveKey } = await import(
  177. "@/lib/security/api-key-auth-cache"
  178. );
  179. const key = buildKey({ key: "sk-roundtrip" });
  180. await cacheActiveKey(key);
  181. const cached = await getCachedActiveKey("sk-roundtrip");
  182. expect(cached?.key).toBe("sk-roundtrip");
  183. expect(cached?.id).toBe(1);
  184. expect(cached?.userId).toBe(10);
  185. expect(cached?.createdAt).toBeInstanceOf(Date);
  186. expect(cached?.updatedAt).toBeInstanceOf(Date);
  187. expect(cached?.createdAt.toISOString()).toBe(key.createdAt.toISOString());
  188. expect(cached?.updatedAt.toISOString()).toBe(key.updatedAt.toISOString());
  189. });
  190. test("getCachedActiveKey:payload 版本不匹配时应删除缓存并返回 null", async () => {
  191. const { getCachedActiveKey } = await import("@/lib/security/api-key-auth-cache");
  192. const keyString = "sk-version-mismatch";
  193. const redisKey = `api_key_auth:v1:key:${sha256HexNode(keyString)}`;
  194. currentRedis!.store.set(
  195. redisKey,
  196. JSON.stringify({
  197. v: 999,
  198. key: {
  199. id: 1,
  200. userId: 10,
  201. name: "k1",
  202. isEnabled: true,
  203. canLoginWebUi: true,
  204. dailyResetMode: "fixed",
  205. dailyResetTime: "00:00",
  206. limitConcurrentSessions: 0,
  207. createdAt: "2026-01-01T00:00:00.000Z",
  208. updatedAt: "2026-01-02T00:00:00.000Z",
  209. },
  210. })
  211. );
  212. await expect(getCachedActiveKey(keyString)).resolves.toBeNull();
  213. expect(currentRedis!.del).toHaveBeenCalledWith(redisKey);
  214. });
  215. describe("getCachedActiveKey:disabled/deleted/expired 应视为失效并清理", () => {
  216. const cases = [
  217. { name: "disabled", payload: { isEnabled: false } },
  218. { name: "deleted", payload: { deletedAt: "2026-01-01T00:00:00.000Z" } },
  219. { name: "expired", payload: { expiresAt: "2026-01-01T00:00:00.000Z" } },
  220. ] as const;
  221. test.each(cases)("$name", async ({ name, payload }) => {
  222. vi.useFakeTimers();
  223. vi.setSystemTime(new Date("2026-01-10T00:00:00.000Z"));
  224. const { getCachedActiveKey } = await import("@/lib/security/api-key-auth-cache");
  225. const keyString = `sk-${name}`;
  226. const redisKey = `api_key_auth:v1:key:${sha256HexNode(keyString)}`;
  227. currentRedis!.store.set(
  228. redisKey,
  229. JSON.stringify({
  230. v: 1,
  231. key: {
  232. id: 1,
  233. userId: 10,
  234. name: "k1",
  235. isEnabled: true,
  236. canLoginWebUi: true,
  237. dailyResetMode: "fixed",
  238. dailyResetTime: "00:00",
  239. limitConcurrentSessions: 0,
  240. createdAt: "2026-01-01T00:00:00.000Z",
  241. updatedAt: "2026-01-02T00:00:00.000Z",
  242. ...payload,
  243. },
  244. })
  245. );
  246. await expect(getCachedActiveKey(keyString)).resolves.toBeNull();
  247. expect(currentRedis!.del).toHaveBeenCalledWith(redisKey);
  248. });
  249. });
  250. describe("cacheActiveKey:非活跃 key(禁用/已删/已过期/无效 expiresAt)应删除缓存,不应 setex", () => {
  251. const cases: Array<{ name: string; key: Key }> = [
  252. { name: "disabled", key: buildKey({ key: "sk-disabled", isEnabled: false }) },
  253. {
  254. name: "deleted",
  255. key: buildKey({ key: "sk-deleted", deletedAt: new Date("2026-01-01T00:00:00.000Z") }),
  256. },
  257. {
  258. name: "expired",
  259. key: buildKey({ key: "sk-expired", expiresAt: new Date("2026-01-01T00:00:00.000Z") }),
  260. },
  261. {
  262. name: "invalid_expiresAt",
  263. // @ts-expect-error: 覆盖运行时边界
  264. key: buildKey({ key: "sk-invalid", expiresAt: "not-a-date" }),
  265. },
  266. ];
  267. test.each(cases)("$name", async ({ key }) => {
  268. vi.useFakeTimers();
  269. vi.setSystemTime(new Date("2026-01-10T00:00:00.000Z"));
  270. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  271. await cacheActiveKey(key);
  272. const expectedRedisKey = `api_key_auth:v1:key:${sha256HexNode(key.key)}`;
  273. expect(currentRedis!.setex).not.toHaveBeenCalled();
  274. expect(currentRedis!.del).toHaveBeenCalledWith(expectedRedisKey);
  275. });
  276. });
  277. test("cacheActiveKey:应按 key.expiresAt 剩余时间收敛 TTL(秒)", async () => {
  278. vi.useFakeTimers();
  279. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  280. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  281. const expiresAt = new Date(Date.now() + 30_000);
  282. const key = buildKey({ key: "sk-ttl-cap", expiresAt });
  283. await cacheActiveKey(key);
  284. expect(currentRedis!.setex).toHaveBeenCalledTimes(1);
  285. const [_redisKey, ttlSeconds] = currentRedis!.setex.mock.calls[0];
  286. expect(ttlSeconds).toBe(30);
  287. });
  288. test("API_KEY_AUTH_CACHE_TTL_SECONDS:应 clamp 到最大 3600s", async () => {
  289. setEnv({ API_KEY_AUTH_CACHE_TTL_SECONDS: "999999" });
  290. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  291. const key = buildKey({ key: "sk-ttl-max" });
  292. await cacheActiveKey(key);
  293. expect(currentRedis!.setex).toHaveBeenCalledTimes(1);
  294. const [_redisKey, ttlSeconds] = currentRedis!.setex.mock.calls[0];
  295. expect(ttlSeconds).toBe(3600);
  296. });
  297. test("invalidateCachedKey:应删除对应的 hashed Redis key", async () => {
  298. const { invalidateCachedKey } = await import("@/lib/security/api-key-auth-cache");
  299. const keyString = "sk-invalidate";
  300. await invalidateCachedKey(keyString);
  301. const expectedRedisKey = `api_key_auth:v1:key:${sha256HexNode(keyString)}`;
  302. expect(currentRedis!.del).toHaveBeenCalledWith(expectedRedisKey);
  303. });
  304. test("cacheAuthResult:应使用 pipeline 写入 key cache(并遵守活跃条件)", async () => {
  305. vi.useFakeTimers();
  306. vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
  307. const { cacheAuthResult } = await import("@/lib/security/api-key-auth-cache");
  308. await cacheAuthResult("sk-auth", {
  309. key: buildKey({ key: "sk-auth" }),
  310. user: buildUser({ id: 10 }),
  311. });
  312. expect(currentRedis!.pipeline).toHaveBeenCalledTimes(1);
  313. const pipeline = currentRedis!.pipelines[0];
  314. expect(pipeline.exec).toHaveBeenCalledTimes(1);
  315. const keyRedisKey = `api_key_auth:v1:key:${sha256HexNode("sk-auth")}`;
  316. expect(pipeline.ops.some((op) => op.kind === "setex" && op.key === keyRedisKey)).toBe(true);
  317. });
  318. test("cacheAuthResult:key 非活跃时应 del key cache(避免脏读误放行)", async () => {
  319. const { cacheAuthResult } = await import("@/lib/security/api-key-auth-cache");
  320. await cacheAuthResult("sk-inactive", {
  321. key: buildKey({ key: "sk-inactive", isEnabled: false }),
  322. user: buildUser({ id: 10 }),
  323. });
  324. const keyRedisKey = `api_key_auth:v1:key:${sha256HexNode("sk-inactive")}`;
  325. const pipeline = currentRedis!.pipelines[0];
  326. expect(pipeline.ops.some((op) => op.kind === "del" && op.key === keyRedisKey)).toBe(true);
  327. });
  328. test("ENABLE_API_KEY_REDIS_CACHE=false:应完全禁用缓存(不触发 Redis 调用)", async () => {
  329. setEnv({ ENABLE_API_KEY_REDIS_CACHE: "false" });
  330. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  331. await cacheActiveKey(buildKey({ key: "sk-disabled-by-env" }));
  332. expect(getRedisClient).not.toHaveBeenCalled();
  333. expect(currentRedis!.setex).not.toHaveBeenCalled();
  334. expect(currentRedis!.del).not.toHaveBeenCalled();
  335. });
  336. test("ENABLE_API_KEY_REDIS_CACHE=0:应完全禁用缓存(不触发 Redis 调用)", async () => {
  337. setEnv({ ENABLE_API_KEY_REDIS_CACHE: "0" });
  338. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  339. await cacheActiveKey(buildKey({ key: "sk-disabled-by-env-0" }));
  340. expect(getRedisClient).not.toHaveBeenCalled();
  341. expect(currentRedis!.setex).not.toHaveBeenCalled();
  342. expect(currentRedis!.del).not.toHaveBeenCalled();
  343. });
  344. test("NEXT_RUNTIME=edge:应禁用缓存(避免在 Edge runtime 引入 Node Redis 依赖)", async () => {
  345. setEnv({ NEXT_RUNTIME: "edge" });
  346. const { getCachedActiveKey } = await import("@/lib/security/api-key-auth-cache");
  347. await expect(getCachedActiveKey("sk-edge")).resolves.toBeNull();
  348. expect(getRedisClient).not.toHaveBeenCalled();
  349. });
  350. test("ENABLE_RATE_LIMIT!=true 或缺少 REDIS_URL:应自动回落(不触发 Redis 调用)", async () => {
  351. setEnv({ ENABLE_RATE_LIMIT: "false" });
  352. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  353. await cacheActiveKey(buildKey({ key: "sk-fallback-1" }));
  354. expect(getRedisClient).not.toHaveBeenCalled();
  355. vi.resetModules();
  356. vi.clearAllMocks();
  357. currentRedis = new FakeRedis();
  358. setEnv({ ENABLE_RATE_LIMIT: "true", REDIS_URL: undefined });
  359. const { cacheActiveKey: cacheActiveKey2 } = await import("@/lib/security/api-key-auth-cache");
  360. await cacheActiveKey2(buildKey({ key: "sk-fallback-2" }));
  361. expect(getRedisClient).not.toHaveBeenCalled();
  362. });
  363. test("ENABLE_RATE_LIMIT=1:应允许使用 Redis 缓存(兼容 1/0 写法)", async () => {
  364. setEnv({ ENABLE_RATE_LIMIT: "1" });
  365. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  366. await cacheActiveKey(buildKey({ key: "sk-rate-limit-1" }));
  367. expect(getRedisClient).toHaveBeenCalled();
  368. expect(currentRedis!.setex).toHaveBeenCalledTimes(1);
  369. });
  370. test("crypto.subtle 缺失:sha256Hex 返回 null,应自动回落(不触发 Redis 调用)", async () => {
  371. vi.unstubAllGlobals();
  372. vi.stubGlobal("crypto", {} as unknown as Crypto);
  373. const { cacheActiveKey } = await import("@/lib/security/api-key-auth-cache");
  374. await cacheActiveKey(buildKey({ key: "sk-no-crypto" }));
  375. expect(currentRedis!.setex).not.toHaveBeenCalled();
  376. expect(currentRedis!.del).not.toHaveBeenCalled();
  377. });
  378. test("Redis 异常:get/setex 抛错时应 fail-open(不影响鉴权正确性)", async () => {
  379. const { cacheActiveKey, getCachedActiveKey } = await import(
  380. "@/lib/security/api-key-auth-cache"
  381. );
  382. currentRedis!.setex.mockRejectedValueOnce(new Error("REDIS_DOWN"));
  383. await expect(cacheActiveKey(buildKey({ key: "sk-redis-down" }))).resolves.toBeUndefined();
  384. currentRedis!.get.mockRejectedValueOnce(new Error("REDIS_DOWN"));
  385. await expect(getCachedActiveKey("sk-redis-down")).resolves.toBeNull();
  386. });
  387. });