session-completer.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. let redisClientRef: any = null;
  3. const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
  4. const ORIGINAL_SESSION_TTL = process.env.SESSION_TTL;
  5. vi.mock("@/lib/logger", () => ({
  6. logger: {
  7. debug: vi.fn(),
  8. info: vi.fn(),
  9. warn: vi.fn(),
  10. error: vi.fn(),
  11. trace: vi.fn(),
  12. },
  13. }));
  14. vi.mock("@/lib/redis", () => ({
  15. getRedisClient: () => redisClientRef,
  16. }));
  17. function makeCodexRequestBody(overrides?: Record<string, unknown>): Record<string, unknown> {
  18. return {
  19. model: "gpt-5-codex",
  20. input: [
  21. {
  22. type: "message",
  23. role: "user",
  24. content: [{ type: "input_text", text: "hello" }],
  25. },
  26. ],
  27. ...(overrides ?? {}),
  28. };
  29. }
  30. function makeFakeRedis() {
  31. const store = new Map<string, string>();
  32. const client = {
  33. status: "ready",
  34. get: vi.fn(async (key: string) => store.get(key) ?? null),
  35. set: vi.fn(
  36. async (key: string, value: string, mode?: string, ttlSeconds?: number, nx?: string) => {
  37. if (mode !== "EX" || typeof ttlSeconds !== "number") {
  38. throw new Error("FakeRedis only supports SET key value EX ttl [NX]");
  39. }
  40. if (nx === "NX" && store.has(key)) {
  41. return null;
  42. }
  43. store.set(key, value);
  44. return "OK";
  45. }
  46. ),
  47. };
  48. return { client, store };
  49. }
  50. describe("Codex session completer", () => {
  51. beforeEach(() => {
  52. redisClientRef = null;
  53. if (ORIGINAL_SESSION_TTL === undefined) {
  54. delete process.env.SESSION_TTL;
  55. } else {
  56. process.env.SESSION_TTL = ORIGINAL_SESSION_TTL;
  57. }
  58. });
  59. test("completes body.prompt_cache_key from header session_id", async () => {
  60. const { completeCodexSessionIdentifiers } = await import(
  61. "@/app/v1/_lib/codex/session-completer"
  62. );
  63. const sessionId = "sess_123456789012345678901";
  64. const headers = new Headers({ session_id: sessionId });
  65. const body = makeCodexRequestBody();
  66. const result = await completeCodexSessionIdentifiers({
  67. keyId: 1,
  68. headers,
  69. requestBody: body,
  70. userAgent: "codex_cli_rs/0.50.0",
  71. });
  72. expect(result.applied).toBe(true);
  73. expect(result.sessionId).toBe(sessionId);
  74. expect(body.prompt_cache_key).toBe(sessionId);
  75. expect(body.metadata).toBeUndefined();
  76. expect(headers.get("session_id")).toBe(sessionId);
  77. });
  78. test("completes header session_id from body.prompt_cache_key", async () => {
  79. const { completeCodexSessionIdentifiers } = await import(
  80. "@/app/v1/_lib/codex/session-completer"
  81. );
  82. const promptCacheKey = "019b82ff-08ff-75a3-a203-7e10274fdbd8";
  83. const headers = new Headers();
  84. const body = makeCodexRequestBody({ prompt_cache_key: promptCacheKey });
  85. const result = await completeCodexSessionIdentifiers({
  86. keyId: 1,
  87. headers,
  88. requestBody: body,
  89. userAgent: "codex_cli_rs/0.50.0",
  90. });
  91. expect(result.applied).toBe(true);
  92. expect(result.sessionId).toBe(promptCacheKey);
  93. expect(headers.get("session_id")).toBe(promptCacheKey);
  94. expect(body.prompt_cache_key).toBe(promptCacheKey);
  95. expect(body.metadata).toBeUndefined();
  96. });
  97. test("no-op when both session_id and prompt_cache_key already exist", async () => {
  98. const { completeCodexSessionIdentifiers } = await import(
  99. "@/app/v1/_lib/codex/session-completer"
  100. );
  101. const sessionId = "sess_123456789012345678901";
  102. const headers = new Headers({ session_id: sessionId });
  103. const body = makeCodexRequestBody({ prompt_cache_key: sessionId });
  104. const result = await completeCodexSessionIdentifiers({
  105. keyId: 1,
  106. headers,
  107. requestBody: body,
  108. userAgent: "codex_cli_rs/0.50.0",
  109. });
  110. expect(result.applied).toBe(false);
  111. expect(result.sessionId).toBe(sessionId);
  112. expect(headers.get("session_id")).toBe(sessionId);
  113. expect(body.prompt_cache_key).toBe(sessionId);
  114. });
  115. test("generates a UUID v7 when both identifiers are missing and Redis is unavailable", async () => {
  116. const { completeCodexSessionIdentifiers } = await import(
  117. "@/app/v1/_lib/codex/session-completer"
  118. );
  119. const headers = new Headers();
  120. const body = makeCodexRequestBody();
  121. const result = await completeCodexSessionIdentifiers({
  122. keyId: 1,
  123. headers,
  124. requestBody: body,
  125. userAgent: "codex_cli_rs/0.50.0",
  126. });
  127. expect(result.applied).toBe(true);
  128. expect(result.action).toBe("generated_uuid_v7");
  129. expect(result.sessionId).toMatch(UUID_V7_PATTERN);
  130. expect(headers.get("session_id")).toBe(result.sessionId);
  131. expect(body.prompt_cache_key).toBe(result.sessionId);
  132. expect(body.metadata).toBeUndefined();
  133. });
  134. test("reuses the same generated session id for the same fingerprint when Redis is available", async () => {
  135. const { completeCodexSessionIdentifiers } = await import(
  136. "@/app/v1/_lib/codex/session-completer"
  137. );
  138. const { client: fakeRedis } = makeFakeRedis();
  139. redisClientRef = fakeRedis;
  140. const baseHeaders = new Headers({
  141. "x-forwarded-for": "203.0.113.10",
  142. "user-agent": "codex_cli_rs/0.50.0",
  143. });
  144. const first = await completeCodexSessionIdentifiers({
  145. keyId: 123,
  146. headers: new Headers(baseHeaders),
  147. requestBody: makeCodexRequestBody(),
  148. userAgent: "codex_cli_rs/0.50.0",
  149. });
  150. const second = await completeCodexSessionIdentifiers({
  151. keyId: 123,
  152. headers: new Headers(baseHeaders),
  153. requestBody: makeCodexRequestBody(),
  154. userAgent: "codex_cli_rs/0.50.0",
  155. });
  156. expect(first.action).toBe("generated_uuid_v7");
  157. expect(second.action).toBe("reused_fingerprint_cache");
  158. expect(first.sessionId).toBe(second.sessionId);
  159. expect(first.sessionId).toMatch(UUID_V7_PATTERN);
  160. });
  161. test("completes header session_id when only x-session-id is provided", async () => {
  162. const { completeCodexSessionIdentifiers } = await import(
  163. "@/app/v1/_lib/codex/session-completer"
  164. );
  165. const xSessionId = "sess_123456789012345678902";
  166. const headers = new Headers({ "x-session-id": xSessionId });
  167. const body = makeCodexRequestBody();
  168. const result = await completeCodexSessionIdentifiers({
  169. keyId: 1,
  170. headers,
  171. requestBody: body,
  172. userAgent: "codex_cli_rs/0.50.0",
  173. });
  174. expect(result.applied).toBe(true);
  175. expect(result.sessionId).toBe(xSessionId);
  176. expect(headers.get("session_id")).toBe(xSessionId);
  177. expect(headers.get("x-session-id")).toBe(xSessionId);
  178. expect(body.prompt_cache_key).toBe(xSessionId);
  179. expect(body.metadata).toBeUndefined();
  180. });
  181. test("completes canonical session_id when x-session-id and prompt_cache_key are provided", async () => {
  182. const { completeCodexSessionIdentifiers } = await import(
  183. "@/app/v1/_lib/codex/session-completer"
  184. );
  185. const xSessionId = "sess_123456789012345678904";
  186. const headers = new Headers({ "x-session-id": xSessionId });
  187. const body = makeCodexRequestBody({ prompt_cache_key: xSessionId });
  188. const result = await completeCodexSessionIdentifiers({
  189. keyId: 1,
  190. headers,
  191. requestBody: body,
  192. userAgent: "codex_cli_rs/0.50.0",
  193. });
  194. expect(result.applied).toBe(true);
  195. expect(result.action).toBe("completed_missing_fields");
  196. expect(result.sessionId).toBe(xSessionId);
  197. expect(headers.get("session_id")).toBe(xSessionId);
  198. expect(headers.get("x-session-id")).toBe(xSessionId);
  199. expect(body.prompt_cache_key).toBe(xSessionId);
  200. expect(body.metadata).toBeUndefined();
  201. });
  202. test("does not mutate metadata when metadata exists (metadata is not allowed for Codex upstream)", async () => {
  203. const { completeCodexSessionIdentifiers } = await import(
  204. "@/app/v1/_lib/codex/session-completer"
  205. );
  206. const sessionId = "sess_123456789012345678903";
  207. const headers = new Headers({ session_id: sessionId });
  208. const metadata = { session_id: "sess_aaaaaaaaaaaaaaaaaaaaa", other: "value" };
  209. const body = makeCodexRequestBody({
  210. metadata,
  211. });
  212. const result = await completeCodexSessionIdentifiers({
  213. keyId: 1,
  214. headers,
  215. requestBody: body,
  216. userAgent: "codex_cli_rs/0.50.0",
  217. });
  218. expect(result.applied).toBe(true);
  219. expect(result.action).toBe("completed_missing_fields");
  220. expect(body.metadata).toEqual(metadata);
  221. });
  222. test("uses x-real-ip when x-forwarded-for is absent (fingerprint stability)", async () => {
  223. const { completeCodexSessionIdentifiers } = await import(
  224. "@/app/v1/_lib/codex/session-completer"
  225. );
  226. const { client: fakeRedis } = makeFakeRedis();
  227. redisClientRef = fakeRedis;
  228. const headers = new Headers({
  229. "x-real-ip": "198.51.100.7",
  230. "user-agent": "codex_cli_rs/0.50.0",
  231. });
  232. const first = await completeCodexSessionIdentifiers({
  233. keyId: 999,
  234. headers: new Headers(headers),
  235. requestBody: makeCodexRequestBody(),
  236. userAgent: "codex_cli_rs/0.50.0",
  237. });
  238. const second = await completeCodexSessionIdentifiers({
  239. keyId: 999,
  240. headers: new Headers(headers),
  241. requestBody: makeCodexRequestBody(),
  242. userAgent: "codex_cli_rs/0.50.0",
  243. });
  244. expect(first.action).toBe("generated_uuid_v7");
  245. expect(second.action).toBe("reused_fingerprint_cache");
  246. expect(first.sessionId).toBe(second.sessionId);
  247. });
  248. test("fingerprint skips non-message items and supports string content", async () => {
  249. const { completeCodexSessionIdentifiers } = await import(
  250. "@/app/v1/_lib/codex/session-completer"
  251. );
  252. const { client: fakeRedis } = makeFakeRedis();
  253. redisClientRef = fakeRedis;
  254. const body = makeCodexRequestBody({
  255. input: [
  256. { type: "function_call", call_id: "call_123", name: "tool", arguments: "{}" },
  257. { type: "message", role: "user", content: "hello from string content" },
  258. ],
  259. });
  260. const result = await completeCodexSessionIdentifiers({
  261. keyId: 321,
  262. headers: new Headers({ "x-forwarded-for": "203.0.113.99" }),
  263. requestBody: body,
  264. userAgent: "codex_cli_rs/0.50.0",
  265. });
  266. expect(result.action).toBe("generated_uuid_v7");
  267. expect(result.sessionId).toMatch(UUID_V7_PATTERN);
  268. });
  269. test("does not overwrite non-object metadata", async () => {
  270. const { completeCodexSessionIdentifiers } = await import(
  271. "@/app/v1/_lib/codex/session-completer"
  272. );
  273. const body = makeCodexRequestBody({ metadata: "not-an-object" });
  274. const result = await completeCodexSessionIdentifiers({
  275. keyId: 1,
  276. headers: new Headers(),
  277. requestBody: body,
  278. userAgent: "codex_cli_rs/0.50.0",
  279. });
  280. expect(result.sessionId).toMatch(UUID_V7_PATTERN);
  281. expect(body.metadata).toBe("not-an-object");
  282. });
  283. test("handles Redis NX race by re-reading existing value", async () => {
  284. const { completeCodexSessionIdentifiers } = await import(
  285. "@/app/v1/_lib/codex/session-completer"
  286. );
  287. const existing = "019b82ff-08ff-75a3-a203-7e10274fdbd8";
  288. let sawFirstGet = false;
  289. redisClientRef = {
  290. status: "ready",
  291. get: vi.fn(async () => {
  292. if (!sawFirstGet) {
  293. sawFirstGet = true;
  294. return null;
  295. }
  296. return existing;
  297. }),
  298. set: vi.fn(async (_key: string, _value: string, _ex: string, _ttl: number, nx?: string) => {
  299. // Simulate another request writing between GET and SET NX
  300. if (nx === "NX") return null;
  301. return "OK";
  302. }),
  303. };
  304. const result = await completeCodexSessionIdentifiers({
  305. keyId: 1,
  306. headers: new Headers({ "x-forwarded-for": "203.0.113.10" }),
  307. requestBody: makeCodexRequestBody(),
  308. userAgent: "codex_cli_rs/0.50.0",
  309. });
  310. expect(result.action).toBe("reused_fingerprint_cache");
  311. expect(result.sessionId).toBe(existing);
  312. });
  313. test("fingerprint treats empty input as unknown and still reuses stable session id", async () => {
  314. const { completeCodexSessionIdentifiers } = await import(
  315. "@/app/v1/_lib/codex/session-completer"
  316. );
  317. const { client: fakeRedis } = makeFakeRedis();
  318. redisClientRef = fakeRedis;
  319. const headers = new Headers({
  320. "x-forwarded-for": "203.0.113.77",
  321. "user-agent": "codex_cli_rs/0.50.0",
  322. });
  323. const first = await completeCodexSessionIdentifiers({
  324. keyId: 42,
  325. headers: new Headers(headers),
  326. requestBody: makeCodexRequestBody({ input: [] }),
  327. userAgent: "codex_cli_rs/0.50.0",
  328. });
  329. const second = await completeCodexSessionIdentifiers({
  330. keyId: 42,
  331. headers: new Headers(headers),
  332. requestBody: makeCodexRequestBody({ input: [] }),
  333. userAgent: "codex_cli_rs/0.50.0",
  334. });
  335. expect(first.action).toBe("generated_uuid_v7");
  336. expect(second.action).toBe("reused_fingerprint_cache");
  337. expect(first.sessionId).toBe(second.sessionId);
  338. });
  339. test("fingerprint only uses first 3 message texts (extra messages do not affect reuse)", async () => {
  340. const { completeCodexSessionIdentifiers } = await import(
  341. "@/app/v1/_lib/codex/session-completer"
  342. );
  343. const { client: fakeRedis } = makeFakeRedis();
  344. redisClientRef = fakeRedis;
  345. const headers = new Headers({
  346. "x-forwarded-for": "203.0.113.88",
  347. "user-agent": "codex_cli_rs/0.50.0",
  348. });
  349. const firstBody = makeCodexRequestBody({
  350. input: [
  351. { type: "message", role: "user", content: "m1" },
  352. { type: "message", role: "user", content: "m2" },
  353. { type: "message", role: "user", content: "m3" },
  354. { type: "message", role: "user", content: "m4-first" },
  355. ],
  356. });
  357. const secondBody = makeCodexRequestBody({
  358. input: [
  359. { type: "message", role: "user", content: "m1" },
  360. { type: "message", role: "user", content: "m2" },
  361. { type: "message", role: "user", content: "m3" },
  362. { type: "message", role: "user", content: "m4-changed" },
  363. ],
  364. });
  365. const first = await completeCodexSessionIdentifiers({
  366. keyId: 43,
  367. headers: new Headers(headers),
  368. requestBody: firstBody,
  369. userAgent: "codex_cli_rs/0.50.0",
  370. });
  371. const second = await completeCodexSessionIdentifiers({
  372. keyId: 43,
  373. headers: new Headers(headers),
  374. requestBody: secondBody,
  375. userAgent: "codex_cli_rs/0.50.0",
  376. });
  377. expect(first.action).toBe("generated_uuid_v7");
  378. expect(second.action).toBe("reused_fingerprint_cache");
  379. expect(first.sessionId).toBe(second.sessionId);
  380. });
  381. test("handles Redis NX fallback by setting without NX when second read is still missing", async () => {
  382. const { completeCodexSessionIdentifiers } = await import(
  383. "@/app/v1/_lib/codex/session-completer"
  384. );
  385. redisClientRef = {
  386. status: "ready",
  387. get: vi.fn(async () => null),
  388. set: vi.fn(async (_key: string, _value: string, _ex: string, _ttl: number, nx?: string) => {
  389. if (nx === "NX") return null;
  390. return "OK";
  391. }),
  392. };
  393. const result = await completeCodexSessionIdentifiers({
  394. keyId: 1,
  395. headers: new Headers({ "x-forwarded-for": "203.0.113.10" }),
  396. requestBody: makeCodexRequestBody(),
  397. userAgent: "codex_cli_rs/0.50.0",
  398. });
  399. expect(result.action).toBe("generated_uuid_v7");
  400. expect(result.sessionId).toMatch(UUID_V7_PATTERN);
  401. expect(redisClientRef.set).toHaveBeenCalledTimes(2);
  402. expect(redisClientRef.set.mock.calls[0]?.[4]).toBe("NX");
  403. expect(redisClientRef.set.mock.calls[1]?.[4]).toBeUndefined();
  404. });
  405. test("falls back to UUID v7 when Redis throws", async () => {
  406. const { completeCodexSessionIdentifiers } = await import(
  407. "@/app/v1/_lib/codex/session-completer"
  408. );
  409. const { logger } = await import("@/lib/logger");
  410. redisClientRef = {
  411. status: "ready",
  412. get: vi.fn(async () => {
  413. throw new Error("boom");
  414. }),
  415. set: vi.fn(async () => "OK"),
  416. };
  417. const result = await completeCodexSessionIdentifiers({
  418. keyId: 1,
  419. headers: new Headers({ "x-forwarded-for": "203.0.113.10" }),
  420. requestBody: makeCodexRequestBody(),
  421. userAgent: "codex_cli_rs/0.50.0",
  422. });
  423. expect(result.action).toBe("generated_uuid_v7");
  424. expect(result.sessionId).toMatch(UUID_V7_PATTERN);
  425. expect(logger.warn).toHaveBeenCalled();
  426. });
  427. test("uses SESSION_TTL when it is a valid integer", async () => {
  428. const { completeCodexSessionIdentifiers } = await import(
  429. "@/app/v1/_lib/codex/session-completer"
  430. );
  431. process.env.SESSION_TTL = "600";
  432. const { client: fakeRedis } = makeFakeRedis();
  433. redisClientRef = fakeRedis;
  434. const result = await completeCodexSessionIdentifiers({
  435. keyId: 1,
  436. headers: new Headers({ "x-forwarded-for": "203.0.113.10" }),
  437. requestBody: makeCodexRequestBody(),
  438. userAgent: "codex_cli_rs/0.50.0",
  439. });
  440. expect(result.sessionId).toMatch(UUID_V7_PATTERN);
  441. });
  442. test("treats invalid session_id as missing and generates a new one", async () => {
  443. const { completeCodexSessionIdentifiers } = await import(
  444. "@/app/v1/_lib/codex/session-completer"
  445. );
  446. const headers = new Headers({ session_id: "short_id_12345" });
  447. const body = makeCodexRequestBody();
  448. const result = await completeCodexSessionIdentifiers({
  449. keyId: 1,
  450. headers,
  451. requestBody: body,
  452. userAgent: "codex_cli_rs/0.50.0",
  453. });
  454. expect(result.sessionId).not.toBe("short_id_12345");
  455. expect(result.sessionId).toMatch(UUID_V7_PATTERN);
  456. expect(headers.get("session_id")).toBe(result.sessionId);
  457. expect(body.prompt_cache_key).toBe(result.sessionId);
  458. });
  459. });