client-guard.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import { describe, expect, test, vi } from "vitest";
  2. import { ProxyClientGuard } from "@/app/v1/_lib/proxy/client-guard";
  3. import type { ProxySession } from "@/app/v1/_lib/proxy/session";
  4. // Mock ProxyResponses
  5. vi.mock("@/app/v1/_lib/proxy/responses", () => ({
  6. ProxyResponses: {
  7. buildError: (status: number, message: string, code: string) =>
  8. new Response(JSON.stringify({ error: { message, type: code } }), { status }),
  9. },
  10. }));
  11. // Helper to create mock session
  12. function createMockSession(
  13. userAgent: string | undefined,
  14. allowedClients: string[] = [],
  15. blockedClients: string[] = []
  16. ): ProxySession {
  17. return {
  18. userAgent,
  19. headers: new Headers(),
  20. request: { message: {} },
  21. authState: {
  22. user: {
  23. allowedClients,
  24. blockedClients,
  25. },
  26. },
  27. } as unknown as ProxySession;
  28. }
  29. // Helper for fully-confirmed Claude Code CLI sessions (all 4 signals)
  30. function createClaudeCodeSession(
  31. userAgent: string,
  32. allowedClients: string[] = [],
  33. blockedClients: string[] = [],
  34. betaHeader = "claude-code-test"
  35. ): ProxySession {
  36. const headers = new Headers();
  37. headers.set("x-app", "cli");
  38. headers.set("anthropic-beta", betaHeader);
  39. return {
  40. userAgent,
  41. headers,
  42. request: { message: { metadata: { user_id: "user_123" } } },
  43. authState: {
  44. user: { allowedClients, blockedClients },
  45. },
  46. } as unknown as ProxySession;
  47. }
  48. describe("ProxyClientGuard", () => {
  49. describe("when authState is missing", () => {
  50. test("should allow request when authState is undefined", async () => {
  51. const session = { userAgent: "SomeClient/1.0" } as unknown as ProxySession;
  52. const result = await ProxyClientGuard.ensure(session);
  53. expect(result).toBeNull();
  54. });
  55. test("should allow request when authState.user is undefined", async () => {
  56. const session = {
  57. userAgent: "SomeClient/1.0",
  58. authState: {},
  59. } as unknown as ProxySession;
  60. const result = await ProxyClientGuard.ensure(session);
  61. expect(result).toBeNull();
  62. });
  63. });
  64. describe("when no restrictions configured", () => {
  65. test("should allow request when allowedClients is empty", async () => {
  66. const session = createMockSession("AnyClient/1.0", []);
  67. const result = await ProxyClientGuard.ensure(session);
  68. expect(result).toBeNull();
  69. });
  70. test("should allow request when allowedClients is undefined", async () => {
  71. const session = createMockSession("AnyClient/1.0", undefined as unknown as string[]);
  72. const result = await ProxyClientGuard.ensure(session);
  73. expect(result).toBeNull();
  74. });
  75. });
  76. describe("when both allowedClients and blockedClients are empty", () => {
  77. test("should allow request", async () => {
  78. const session = createMockSession("AnyClient/1.0", [], []);
  79. const result = await ProxyClientGuard.ensure(session);
  80. expect(result).toBeNull();
  81. });
  82. });
  83. describe("when restrictions are configured", () => {
  84. test("should reject when User-Agent is missing", async () => {
  85. const session = createMockSession(undefined, ["claude-cli"]);
  86. const result = await ProxyClientGuard.ensure(session);
  87. expect(result).not.toBeNull();
  88. expect(result?.status).toBe(400);
  89. });
  90. test("should reject when User-Agent is empty", async () => {
  91. const session = createMockSession("", ["claude-cli"]);
  92. const result = await ProxyClientGuard.ensure(session);
  93. expect(result).not.toBeNull();
  94. expect(result?.status).toBe(400);
  95. });
  96. test("should reject when User-Agent is whitespace only", async () => {
  97. const session = createMockSession(" ", ["claude-cli"]);
  98. const result = await ProxyClientGuard.ensure(session);
  99. expect(result).not.toBeNull();
  100. expect(result?.status).toBe(400);
  101. });
  102. });
  103. describe("pattern matching with hyphen/underscore normalization", () => {
  104. test("should match gemini-cli pattern against GeminiCLI User-Agent", async () => {
  105. const session = createMockSession("GeminiCLI/0.22.5/gemini-3-pro-preview (darwin; arm64)", [
  106. "gemini-cli",
  107. ]);
  108. const result = await ProxyClientGuard.ensure(session);
  109. expect(result).toBeNull();
  110. });
  111. test("should match claude-cli pattern against claude_cli User-Agent", async () => {
  112. const session = createMockSession("claude_cli/1.0", ["claude-cli"]);
  113. const result = await ProxyClientGuard.ensure(session);
  114. expect(result).toBeNull();
  115. });
  116. test("should match codex-cli pattern against codexcli User-Agent", async () => {
  117. const session = createMockSession("codexcli/2.0", ["codex-cli"]);
  118. const result = await ProxyClientGuard.ensure(session);
  119. expect(result).toBeNull();
  120. });
  121. test("should match factory-cli pattern against FactoryCLI User-Agent", async () => {
  122. const session = createMockSession("FactoryCLI/1.0", ["factory-cli"]);
  123. const result = await ProxyClientGuard.ensure(session);
  124. expect(result).toBeNull();
  125. });
  126. test("should be case-insensitive", async () => {
  127. const session = createMockSession("GEMINICLI/1.0", ["gemini-cli"]);
  128. const result = await ProxyClientGuard.ensure(session);
  129. expect(result).toBeNull();
  130. });
  131. });
  132. describe("pattern matching without normalization needed", () => {
  133. test("should match exact substring", async () => {
  134. const session = createMockSession("claude-cli/1.0.0", ["claude-cli"]);
  135. const result = await ProxyClientGuard.ensure(session);
  136. expect(result).toBeNull();
  137. });
  138. test("should match when User-Agent contains pattern as substring", async () => {
  139. const session = createMockSession("Mozilla/5.0 claude-cli/1.0 Compatible", ["claude-cli"]);
  140. const result = await ProxyClientGuard.ensure(session);
  141. expect(result).toBeNull();
  142. });
  143. });
  144. describe("multiple patterns", () => {
  145. test("should allow when one of multiple patterns matches", async () => {
  146. const session = createMockSession("GeminiCLI/1.0", ["claude-cli", "gemini-cli", "codex-cli"]);
  147. const result = await ProxyClientGuard.ensure(session);
  148. expect(result).toBeNull();
  149. });
  150. test("should reject when no patterns match", async () => {
  151. const session = createMockSession("UnknownClient/1.0", ["claude-cli", "gemini-cli"]);
  152. const result = await ProxyClientGuard.ensure(session);
  153. expect(result).not.toBeNull();
  154. expect(result?.status).toBe(400);
  155. });
  156. });
  157. describe("edge cases", () => {
  158. test("should handle pattern with multiple hyphens", async () => {
  159. const session = createMockSession("my-special-cli/1.0", ["my-special-cli"]);
  160. const result = await ProxyClientGuard.ensure(session);
  161. expect(result).toBeNull();
  162. });
  163. test("should handle pattern with underscores", async () => {
  164. const session = createMockSession("my_special_cli/1.0", ["my-special-cli"]);
  165. const result = await ProxyClientGuard.ensure(session);
  166. expect(result).toBeNull();
  167. });
  168. test("should handle mixed hyphen and underscore", async () => {
  169. const session = createMockSession("my_special-cli/1.0", ["my-special_cli"]);
  170. const result = await ProxyClientGuard.ensure(session);
  171. expect(result).toBeNull();
  172. });
  173. test("should reject when pattern normalizes to empty string", async () => {
  174. const session = createMockSession("AnyClient/1.0", ["-"]);
  175. const result = await ProxyClientGuard.ensure(session);
  176. expect(result).not.toBeNull();
  177. expect(result?.status).toBe(400);
  178. });
  179. test("should reject when pattern is only underscores", async () => {
  180. const session = createMockSession("AnyClient/1.0", ["___"]);
  181. const result = await ProxyClientGuard.ensure(session);
  182. expect(result).not.toBeNull();
  183. expect(result?.status).toBe(400);
  184. });
  185. test("should reject when pattern is only hyphens and underscores", async () => {
  186. const session = createMockSession("AnyClient/1.0", ["-_-_-"]);
  187. const result = await ProxyClientGuard.ensure(session);
  188. expect(result).not.toBeNull();
  189. expect(result?.status).toBe(400);
  190. });
  191. test("should reject when all patterns normalize to empty", async () => {
  192. const session = createMockSession("AnyClient/1.0", ["-", "_", "--"]);
  193. const result = await ProxyClientGuard.ensure(session);
  194. expect(result).not.toBeNull();
  195. expect(result?.status).toBe(400);
  196. });
  197. test("should allow when at least one pattern is valid after normalization", async () => {
  198. const session = createMockSession("ValidClient/1.0", ["-", "valid", "_"]);
  199. const result = await ProxyClientGuard.ensure(session);
  200. expect(result).toBeNull();
  201. });
  202. });
  203. describe("when blockedClients is configured", () => {
  204. test("should reject when client matches blocked pattern", async () => {
  205. const session = createMockSession("GeminiCLI/1.0", [], ["gemini-cli"]);
  206. const result = await ProxyClientGuard.ensure(session);
  207. expect(result).not.toBeNull();
  208. expect(result?.status).toBe(400);
  209. });
  210. test("should allow when client does not match blocked pattern", async () => {
  211. const session = createMockSession("CodexCLI/1.0", [], ["gemini-cli"]);
  212. const result = await ProxyClientGuard.ensure(session);
  213. expect(result).toBeNull();
  214. });
  215. test("should reject even when allowedClients matches", async () => {
  216. const session = createMockSession("gemini-cli/1.0", ["gemini-cli"], ["gemini-cli"]);
  217. const result = await ProxyClientGuard.ensure(session);
  218. expect(result).not.toBeNull();
  219. expect(result?.status).toBe(400);
  220. });
  221. });
  222. describe("when only blockedClients is configured (no allowedClients)", () => {
  223. test("should reject matching client", async () => {
  224. const session = createMockSession("codex-cli/2.0", [], ["codex-cli"]);
  225. const result = await ProxyClientGuard.ensure(session);
  226. expect(result).not.toBeNull();
  227. expect(result?.status).toBe(400);
  228. });
  229. test("should allow non-matching client", async () => {
  230. const session = createMockSession("claude-cli/1.0", [], ["codex-cli"]);
  231. const result = await ProxyClientGuard.ensure(session);
  232. expect(result).toBeNull();
  233. });
  234. });
  235. describe("claude-code builtin keyword with 4-signal detection", () => {
  236. test("should allow claude-cli/2.0.70 with non-claude-code beta header (the bug fix)", async () => {
  237. // CLI 2.0.70 sends interleaved-thinking-2025-05-14, not a claude-code-* beta
  238. const session = createClaudeCodeSession(
  239. "claude-cli/2.0.70 (external, cli)",
  240. ["claude-code"],
  241. [],
  242. "interleaved-thinking-2025-05-14"
  243. );
  244. const result = await ProxyClientGuard.ensure(session);
  245. expect(result).toBeNull();
  246. });
  247. test("should reject when metadata.user_id is missing (only 3-of-4 signals)", async () => {
  248. const headers = new Headers();
  249. headers.set("x-app", "cli");
  250. headers.set("anthropic-beta", "some-beta");
  251. const session = {
  252. userAgent: "claude-cli/2.0.70 (external, cli)",
  253. headers,
  254. request: { message: {} },
  255. authState: { user: { allowedClients: ["claude-code"], blockedClients: [] } },
  256. } as unknown as import("@/app/v1/_lib/proxy/session").ProxySession;
  257. const result = await ProxyClientGuard.ensure(session);
  258. expect(result).not.toBeNull();
  259. expect(result?.status).toBe(400);
  260. const body = await result!.json();
  261. expect(body.error.message).toContain("Signals(3/4)");
  262. });
  263. test("should reject when anthropic-beta header is missing (only 3-of-4 signals)", async () => {
  264. const headers = new Headers();
  265. headers.set("x-app", "cli");
  266. const session = {
  267. userAgent: "claude-cli/2.0.70 (external, cli)",
  268. headers,
  269. request: { message: { metadata: { user_id: "user_abc" } } },
  270. authState: { user: { allowedClients: ["claude-code"], blockedClients: [] } },
  271. } as unknown as import("@/app/v1/_lib/proxy/session").ProxySession;
  272. const result = await ProxyClientGuard.ensure(session);
  273. expect(result).not.toBeNull();
  274. expect(result?.status).toBe(400);
  275. const body = await result!.json();
  276. expect(body.error.message).toContain("Signals(3/4)");
  277. });
  278. test("should allow when all 4 signals present with claude-code allowlist", async () => {
  279. const session = createClaudeCodeSession("claude-cli/1.0.0 (external, cli)", ["claude-code"]);
  280. const result = await ProxyClientGuard.ensure(session);
  281. expect(result).toBeNull();
  282. });
  283. });
  284. });