rate-limit-guard.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. const rateLimitServiceMock = {
  3. checkTotalCostLimit: vi.fn(),
  4. checkSessionLimit: vi.fn(),
  5. checkRpmLimit: vi.fn(),
  6. checkCostLimitsWithLease: vi.fn(),
  7. checkUserDailyCost: vi.fn(),
  8. };
  9. vi.mock("@/lib/rate-limit", () => ({
  10. RateLimitService: rateLimitServiceMock,
  11. }));
  12. vi.mock("@/lib/logger", () => ({
  13. logger: {
  14. warn: vi.fn(),
  15. info: vi.fn(),
  16. error: vi.fn(),
  17. debug: vi.fn(),
  18. },
  19. }));
  20. vi.mock("next-intl/server", () => ({
  21. getLocale: vi.fn(async () => "zh-CN"),
  22. }));
  23. const getErrorMessageServerMock = vi.fn(async () => "mock rate limit message");
  24. vi.mock("@/lib/utils/error-messages", () => ({
  25. ERROR_CODES: {
  26. RATE_LIMIT_TOTAL_EXCEEDED: "RATE_LIMIT_TOTAL_EXCEEDED",
  27. RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED: "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED",
  28. RATE_LIMIT_RPM_EXCEEDED: "RATE_LIMIT_RPM_EXCEEDED",
  29. RATE_LIMIT_DAILY_QUOTA_EXCEEDED: "RATE_LIMIT_DAILY_QUOTA_EXCEEDED",
  30. RATE_LIMIT_5H_EXCEEDED: "RATE_LIMIT_5H_EXCEEDED",
  31. RATE_LIMIT_WEEKLY_EXCEEDED: "RATE_LIMIT_WEEKLY_EXCEEDED",
  32. RATE_LIMIT_MONTHLY_EXCEEDED: "RATE_LIMIT_MONTHLY_EXCEEDED",
  33. },
  34. getErrorMessageServer: getErrorMessageServerMock,
  35. }));
  36. describe("ProxyRateLimitGuard - key daily limit enforcement", () => {
  37. const createSession = (overrides?: {
  38. user?: Partial<{
  39. id: number;
  40. rpm: number | null;
  41. dailyQuota: number | null;
  42. dailyResetMode: "fixed" | "rolling";
  43. dailyResetTime: string;
  44. limit5hUsd: number | null;
  45. limitWeeklyUsd: number | null;
  46. limitMonthlyUsd: number | null;
  47. limitTotalUsd: number | null;
  48. limitConcurrentSessions: number | null;
  49. }>;
  50. key?: Partial<{
  51. id: number;
  52. key: string;
  53. limit5hUsd: number | null;
  54. limitDailyUsd: number | null;
  55. dailyResetMode: "fixed" | "rolling";
  56. dailyResetTime: string;
  57. limitWeeklyUsd: number | null;
  58. limitMonthlyUsd: number | null;
  59. limitTotalUsd: number | null;
  60. limitConcurrentSessions: number;
  61. }>;
  62. }) => {
  63. return {
  64. authState: {
  65. user: {
  66. id: 1,
  67. rpm: null,
  68. dailyQuota: null,
  69. dailyResetMode: "fixed",
  70. dailyResetTime: "00:00",
  71. limit5hUsd: null,
  72. limitWeeklyUsd: null,
  73. limitMonthlyUsd: null,
  74. limitTotalUsd: null,
  75. limitConcurrentSessions: null,
  76. ...overrides?.user,
  77. },
  78. key: {
  79. id: 2,
  80. key: "k_test",
  81. limit5hUsd: null,
  82. limitDailyUsd: null,
  83. dailyResetMode: "fixed",
  84. dailyResetTime: "00:00",
  85. limitWeeklyUsd: null,
  86. limitMonthlyUsd: null,
  87. limitTotalUsd: null,
  88. limitConcurrentSessions: 0,
  89. ...overrides?.key,
  90. },
  91. },
  92. } as any;
  93. };
  94. beforeEach(() => {
  95. vi.clearAllMocks();
  96. rateLimitServiceMock.checkTotalCostLimit.mockResolvedValue({ allowed: true });
  97. rateLimitServiceMock.checkSessionLimit.mockResolvedValue({ allowed: true });
  98. rateLimitServiceMock.checkRpmLimit.mockResolvedValue({ allowed: true });
  99. rateLimitServiceMock.checkUserDailyCost.mockResolvedValue({ allowed: true });
  100. rateLimitServiceMock.checkCostLimitsWithLease.mockResolvedValue({ allowed: true });
  101. });
  102. it("当用户未设置每日额度时,Key 每日额度已超限也必须拦截", async () => {
  103. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  104. rateLimitServiceMock.checkCostLimitsWithLease
  105. .mockResolvedValueOnce({ allowed: true }) // key 5h
  106. .mockResolvedValueOnce({ allowed: true }) // user 5h
  107. .mockResolvedValueOnce({
  108. allowed: false,
  109. reason: "Key daily cost limit reached (usage: 20.0000/10.0000)",
  110. }); // key daily
  111. const session = createSession({
  112. user: { dailyQuota: null },
  113. key: { limitDailyUsd: 10 },
  114. });
  115. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  116. name: "RateLimitError",
  117. limitType: "daily_quota",
  118. currentUsage: 20,
  119. limitValue: 10,
  120. });
  121. expect(rateLimitServiceMock.checkUserDailyCost).not.toHaveBeenCalled();
  122. expect(rateLimitServiceMock.checkCostLimitsWithLease).toHaveBeenCalledWith(2, "key", {
  123. limit_5h_usd: null,
  124. limit_daily_usd: 10,
  125. daily_reset_mode: "fixed",
  126. daily_reset_time: "00:00",
  127. limit_weekly_usd: null,
  128. limit_monthly_usd: null,
  129. });
  130. });
  131. it("当 Key 每日额度超限时,应在用户每日检查之前直接拦截(Key 优先)", async () => {
  132. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  133. rateLimitServiceMock.checkCostLimitsWithLease
  134. .mockResolvedValueOnce({ allowed: true }) // key 5h
  135. .mockResolvedValueOnce({ allowed: true }) // user 5h
  136. .mockResolvedValueOnce({
  137. allowed: false,
  138. reason: "Key daily cost limit reached (usage: 20.0000/10.0000)",
  139. }); // key daily
  140. const session = createSession({
  141. user: { dailyQuota: 999 },
  142. key: { limitDailyUsd: 10 },
  143. });
  144. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  145. name: "RateLimitError",
  146. limitType: "daily_quota",
  147. });
  148. expect(rateLimitServiceMock.checkUserDailyCost).not.toHaveBeenCalled();
  149. });
  150. it("当 Key 未设置每日额度且用户每日额度已超限时,仍应拦截用户每日额度", async () => {
  151. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  152. rateLimitServiceMock.checkCostLimitsWithLease
  153. .mockResolvedValueOnce({ allowed: true }) // key 5h
  154. .mockResolvedValueOnce({ allowed: true }) // user 5h
  155. .mockResolvedValueOnce({ allowed: true }) // key daily (limit null)
  156. .mockResolvedValueOnce({
  157. allowed: false,
  158. reason: "User daily cost limit reached (usage: 20.0000/10.0000)",
  159. }); // user daily
  160. const session = createSession({
  161. user: { dailyQuota: 10 },
  162. key: { limitDailyUsd: null },
  163. });
  164. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  165. name: "RateLimitError",
  166. limitType: "daily_quota",
  167. currentUsage: 20,
  168. limitValue: 10,
  169. });
  170. // User daily 现在使用 checkCostLimitsWithLease 而不是 checkUserDailyCost
  171. expect(rateLimitServiceMock.checkUserDailyCost).not.toHaveBeenCalled();
  172. expect(rateLimitServiceMock.checkCostLimitsWithLease).toHaveBeenCalledWith(1, "user", {
  173. limit_5h_usd: null,
  174. limit_daily_usd: 10,
  175. daily_reset_time: "00:00",
  176. daily_reset_mode: "fixed",
  177. limit_weekly_usd: null,
  178. limit_monthly_usd: null,
  179. });
  180. });
  181. it("Key 总限额超限应拦截(usd_total)", async () => {
  182. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  183. rateLimitServiceMock.checkTotalCostLimit.mockResolvedValueOnce({
  184. allowed: false,
  185. current: 20,
  186. reason: "Key total limit exceeded",
  187. });
  188. const session = createSession({
  189. key: { limitTotalUsd: 10 },
  190. });
  191. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  192. name: "RateLimitError",
  193. limitType: "usd_total",
  194. currentUsage: 20,
  195. limitValue: 10,
  196. });
  197. });
  198. it("User 总限额超限应拦截(usd_total)", async () => {
  199. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  200. rateLimitServiceMock.checkTotalCostLimit
  201. .mockResolvedValueOnce({ allowed: true }) // key total
  202. .mockResolvedValueOnce({ allowed: false, current: 20, reason: "User total limit exceeded" }); // user total
  203. const session = createSession({
  204. user: { limitTotalUsd: 10 },
  205. });
  206. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  207. name: "RateLimitError",
  208. limitType: "usd_total",
  209. currentUsage: 20,
  210. limitValue: 10,
  211. });
  212. });
  213. it("Key 并发 Session 超限应拦截(concurrent_sessions)", async () => {
  214. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  215. rateLimitServiceMock.checkSessionLimit.mockResolvedValueOnce({
  216. allowed: false,
  217. reason: "Key并发 Session 上限已达到(2/1)",
  218. });
  219. const session = createSession({
  220. key: { limitConcurrentSessions: 1 },
  221. });
  222. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  223. name: "RateLimitError",
  224. limitType: "concurrent_sessions",
  225. currentUsage: 2,
  226. limitValue: 1,
  227. });
  228. });
  229. it("User 并发 Session 超限应拦截(concurrent_sessions)", async () => {
  230. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  231. rateLimitServiceMock.checkSessionLimit
  232. .mockResolvedValueOnce({ allowed: true }) // key session
  233. .mockResolvedValueOnce({ allowed: false, reason: "User并发 Session 上限已达到(2/1)" });
  234. const session = createSession({
  235. user: { limitConcurrentSessions: 1 },
  236. key: { limitConcurrentSessions: 10 },
  237. });
  238. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  239. name: "RateLimitError",
  240. limitType: "concurrent_sessions",
  241. currentUsage: 2,
  242. limitValue: 1,
  243. });
  244. });
  245. it("当 Key 并发未设置(0)且 User 并发已设置时,Key 并发检查应继承 User 并发上限", async () => {
  246. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  247. const session = createSession({
  248. user: { limitConcurrentSessions: 15 },
  249. key: { limitConcurrentSessions: 0 },
  250. });
  251. await expect(ProxyRateLimitGuard.ensure(session)).resolves.toBeUndefined();
  252. expect(rateLimitServiceMock.checkSessionLimit).toHaveBeenNthCalledWith(1, 2, "key", 15);
  253. expect(rateLimitServiceMock.checkSessionLimit).toHaveBeenNthCalledWith(2, 1, "user", 15);
  254. });
  255. it("User RPM 超限应拦截(rpm)", async () => {
  256. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  257. rateLimitServiceMock.checkRpmLimit.mockResolvedValueOnce({
  258. allowed: false,
  259. current: 10,
  260. reason: "用户每分钟请求数上限已达到(10/5)",
  261. });
  262. const session = createSession({
  263. user: { rpm: 5 },
  264. });
  265. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  266. name: "RateLimitError",
  267. limitType: "rpm",
  268. currentUsage: 10,
  269. limitValue: 5,
  270. });
  271. });
  272. it("Key 5h 超限应拦截(usd_5h)", async () => {
  273. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  274. rateLimitServiceMock.checkCostLimitsWithLease.mockResolvedValueOnce({
  275. allowed: false,
  276. reason: "Key 5h cost limit reached (usage: 20.0000/10.0000)",
  277. });
  278. const session = createSession({
  279. key: { limit5hUsd: 10 },
  280. });
  281. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  282. name: "RateLimitError",
  283. limitType: "usd_5h",
  284. currentUsage: 20,
  285. limitValue: 10,
  286. });
  287. });
  288. it("User 5h 超限应拦截(usd_5h)", async () => {
  289. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  290. rateLimitServiceMock.checkCostLimitsWithLease
  291. .mockResolvedValueOnce({ allowed: true }) // key 5h
  292. .mockResolvedValueOnce({
  293. allowed: false,
  294. reason: "User 5h cost limit reached (usage: 20.0000/10.0000)",
  295. }); // user 5h
  296. const session = createSession({
  297. user: { limit5hUsd: 10 },
  298. });
  299. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  300. name: "RateLimitError",
  301. limitType: "usd_5h",
  302. currentUsage: 20,
  303. limitValue: 10,
  304. });
  305. });
  306. it("Key 周限额超限应拦截(usd_weekly)", async () => {
  307. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  308. rateLimitServiceMock.checkCostLimitsWithLease
  309. .mockResolvedValueOnce({ allowed: true }) // key 5h
  310. .mockResolvedValueOnce({ allowed: true }) // user 5h
  311. .mockResolvedValueOnce({ allowed: true }) // key daily
  312. .mockResolvedValueOnce({ allowed: true }) // user daily (new with lease migration)
  313. .mockResolvedValueOnce({
  314. allowed: false,
  315. reason: "Key weekly cost limit reached (usage: 100.0000/10.0000)",
  316. }); // key weekly
  317. const session = createSession({
  318. key: { limitWeeklyUsd: 10 },
  319. });
  320. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  321. name: "RateLimitError",
  322. limitType: "usd_weekly",
  323. currentUsage: 100,
  324. limitValue: 10,
  325. });
  326. });
  327. it("User 周限额超限应拦截(usd_weekly)", async () => {
  328. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  329. rateLimitServiceMock.checkCostLimitsWithLease
  330. .mockResolvedValueOnce({ allowed: true }) // key 5h
  331. .mockResolvedValueOnce({ allowed: true }) // user 5h
  332. .mockResolvedValueOnce({ allowed: true }) // key daily
  333. .mockResolvedValueOnce({ allowed: true }) // user daily (new with lease migration)
  334. .mockResolvedValueOnce({ allowed: true }) // key weekly
  335. .mockResolvedValueOnce({
  336. allowed: false,
  337. reason: "User weekly cost limit reached (usage: 100.0000/10.0000)",
  338. }); // user weekly
  339. const session = createSession({
  340. user: { limitWeeklyUsd: 10 },
  341. });
  342. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  343. name: "RateLimitError",
  344. limitType: "usd_weekly",
  345. currentUsage: 100,
  346. limitValue: 10,
  347. });
  348. });
  349. it("Key 月限额超限应拦截(usd_monthly)", async () => {
  350. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  351. rateLimitServiceMock.checkCostLimitsWithLease
  352. .mockResolvedValueOnce({ allowed: true }) // key 5h
  353. .mockResolvedValueOnce({ allowed: true }) // user 5h
  354. .mockResolvedValueOnce({ allowed: true }) // key daily
  355. .mockResolvedValueOnce({ allowed: true }) // user daily (new with lease migration)
  356. .mockResolvedValueOnce({ allowed: true }) // key weekly
  357. .mockResolvedValueOnce({ allowed: true }) // user weekly
  358. .mockResolvedValueOnce({
  359. allowed: false,
  360. reason: "Key monthly cost limit reached (usage: 200.0000/10.0000)",
  361. }); // key monthly
  362. const session = createSession({
  363. key: { limitMonthlyUsd: 10 },
  364. });
  365. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  366. name: "RateLimitError",
  367. limitType: "usd_monthly",
  368. currentUsage: 200,
  369. limitValue: 10,
  370. });
  371. });
  372. it("User 月限额超限应拦截(usd_monthly)", async () => {
  373. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  374. rateLimitServiceMock.checkCostLimitsWithLease
  375. .mockResolvedValueOnce({ allowed: true }) // key 5h
  376. .mockResolvedValueOnce({ allowed: true }) // user 5h
  377. .mockResolvedValueOnce({ allowed: true }) // key daily
  378. .mockResolvedValueOnce({ allowed: true }) // user daily (new with lease migration)
  379. .mockResolvedValueOnce({ allowed: true }) // key weekly
  380. .mockResolvedValueOnce({ allowed: true }) // user weekly
  381. .mockResolvedValueOnce({ allowed: true }) // key monthly
  382. .mockResolvedValueOnce({
  383. allowed: false,
  384. reason: "User monthly cost limit reached (usage: 200.0000/10.0000)",
  385. }); // user monthly
  386. const session = createSession({
  387. user: { limitMonthlyUsd: 10 },
  388. });
  389. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  390. name: "RateLimitError",
  391. limitType: "usd_monthly",
  392. currentUsage: 200,
  393. limitValue: 10,
  394. });
  395. });
  396. it("所有限额均未触发时应放行", async () => {
  397. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  398. const session = createSession();
  399. await expect(ProxyRateLimitGuard.ensure(session)).resolves.toBeUndefined();
  400. });
  401. it("User daily (rolling mode) 超限应使用 checkCostLimitsWithLease", async () => {
  402. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  403. rateLimitServiceMock.checkCostLimitsWithLease
  404. .mockResolvedValueOnce({ allowed: true }) // key 5h
  405. .mockResolvedValueOnce({ allowed: true }) // user 5h
  406. .mockResolvedValueOnce({ allowed: true }) // key daily (limit null)
  407. .mockResolvedValueOnce({
  408. allowed: false,
  409. reason: "User daily cost limit reached (usage: 15.0000/10.0000)",
  410. }); // user daily rolling
  411. const session = createSession({
  412. user: { dailyQuota: 10, dailyResetMode: "rolling", dailyResetTime: "12:00" },
  413. key: { limitDailyUsd: null },
  414. });
  415. await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
  416. name: "RateLimitError",
  417. limitType: "daily_quota",
  418. currentUsage: 15,
  419. limitValue: 10,
  420. resetTime: null, // rolling 模式没有固定重置时间
  421. });
  422. // Verify checkCostLimitsWithLease was called with rolling mode
  423. expect(rateLimitServiceMock.checkCostLimitsWithLease).toHaveBeenCalledWith(1, "user", {
  424. limit_5h_usd: null,
  425. limit_daily_usd: 10,
  426. daily_reset_time: "12:00",
  427. daily_reset_mode: "rolling",
  428. limit_weekly_usd: null,
  429. limit_monthly_usd: null,
  430. });
  431. // checkUserDailyCost should NOT be called (migrated to lease)
  432. expect(rateLimitServiceMock.checkUserDailyCost).not.toHaveBeenCalled();
  433. });
  434. it("User daily 检查顺序:Key daily 先于 User daily", async () => {
  435. const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
  436. const callOrder: string[] = [];
  437. rateLimitServiceMock.checkCostLimitsWithLease.mockImplementation(async (_id, type, limits) => {
  438. if (limits.limit_daily_usd !== null) {
  439. callOrder.push(`${type}_daily`);
  440. }
  441. return { allowed: true };
  442. });
  443. const session = createSession({
  444. user: { dailyQuota: 10 },
  445. key: { limitDailyUsd: 20 },
  446. });
  447. await ProxyRateLimitGuard.ensure(session);
  448. // Key daily should be checked before User daily
  449. const keyDailyIdx = callOrder.indexOf("key_daily");
  450. const userDailyIdx = callOrder.indexOf("user_daily");
  451. expect(keyDailyIdx).toBeLessThan(userDailyIdx);
  452. });
  453. });