Mercurial
diff mrjunejune/test/conversation_api_test.js @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | b401627fc49e |
| children | 056790c4fb0d |
line wrap: on
line diff
--- a/mrjunejune/test/conversation_api_test.js Thu Aug 06 11:31:30 2026 -0700 +++ b/mrjunejune/test/conversation_api_test.js Fri Aug 07 07:34:12 2026 -0700 @@ -54,6 +54,90 @@ }); } +/* ------------------------------------------------------------------ */ +/* Cookie jar: tracks Set-Cookie headers across responses */ +/* ------------------------------------------------------------------ */ + +class CookieJar { + constructor() { + this._cookies = new Map(); + } + + /** Update jar from a Set-Cookie header value (single directive). */ + updateFromDirective(directive) { + if (!directive) return; + const firstPart = directive.split(';')[0].trim(); + const eqIdx = firstPart.indexOf('='); + if (eqIdx < 0) return; + const name = firstPart.slice(0, eqIdx).trim(); + const value = firstPart.slice(eqIdx + 1).trim(); + if (!name) return; + // A max-age of -1 or a "deleted" value clears the cookie. + if (/max-age\s*=\s*-?0/i.test(directive) || value === '' || value.toLowerCase() === 'deleted') { + this._cookies.delete(name); + } else { + this._cookies.set(name, value); + } + } + + /** Update jar from a response (handles multiple Set-Cookie headers). */ + updateFromResponse(response) { + let setCookies; + try { + setCookies = response.headers.getSetCookie(); + } catch { + // Fallback for older Node versions + const raw = response.headers.get('set-cookie') || ''; + setCookies = raw ? raw.split(',\n').map(s => s.trim()).filter(Boolean) : []; + } + for (const directive of setCookies) { + this.updateFromDirective(directive); + } + } + + get header() { + return [...this._cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + } + + clone() { + const jar = new CookieJar(); + for (const [k, v] of this._cookies) jar._cookies.set(k, v); + return jar; + } +} + +/* ------------------------------------------------------------------ */ +/* Session bootstrap: GET /api/auth/session → csrfToken + cookie jar */ +/* ------------------------------------------------------------------ */ + +async function bootstrapSession(baseUrl, jar) { + const headers = {}; + if (jar.header) headers['Cookie'] = jar.header; + const response = await fetch(`${baseUrl}/api/auth/session`, { headers }); + assert.equal(response.status, 200); + jar.updateFromResponse(response); + const data = await response.json(); + assert.ok(data.csrfToken, 'session must return csrfToken'); + return data.csrfToken; +} + +/* ------------------------------------------------------------------ */ +/* Fetch helper: adds Cookie + X-CSRF-Token headers */ +/* ------------------------------------------------------------------ */ + +async function authedFetch(baseUrl, jar, csrfToken, path, options = {}) { + const headers = { ...(options.headers || {}) }; + if (jar.header) headers['Cookie'] = jar.header; + if (csrfToken) headers['X-CSRF-Token'] = csrfToken; + const response = await fetch(`${baseUrl}${path}`, { ...options, headers }); + jar.updateFromResponse(response); + return response; +} + +/* ------------------------------------------------------------------ */ +/* Main test suite */ +/* ------------------------------------------------------------------ */ + (async () => { assert.ok(RUNFILES); assert.ok(WORKSPACE); @@ -84,61 +168,197 @@ MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar, MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar, MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE: '1', + MRJUNEJUNE_ALLOW_GUEST_INFERENCE: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }); server.stdout.on('data', chunk => logs.push(chunk.toString())); server.stderr.on('data', chunk => logs.push(chunk.toString())); await waitForServer(server, baseUrl, logs); + + // ---------------------------------------------------------------- + // Inference health check + // ---------------------------------------------------------------- let response = await fetch(`${baseUrl}/api/inference/health`); assert.equal(response.status, 200); + // ---------------------------------------------------------------- + // Task 2: mutation without any session returns 401 (no guest created) + // ---------------------------------------------------------------- + response = await fetch(`${baseUrl}/api/conversations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: baseUrl, + 'X-CSRF-Token': 'some-token', + }, + body: JSON.stringify({ title: 'No session' }), + }); + assert.equal(response.status, 401, 'mutation without session must return 401'); + // Ensure no Set-Cookie guest header was emitted (no guest row written) + const setCookieNoSession = response.headers.get('set-cookie') || ''; + assert.ok( + !setCookieNoSession.includes('mjj_guest'), + 'mutation without session must not set guest cookie', + ); + + // ---------------------------------------------------------------- + // Guest 1: bootstrap session + // ---------------------------------------------------------------- + const jar1 = new CookieJar(); + const csrf1 = await bootstrapSession(baseUrl, jar1); + + // ---------------------------------------------------------------- + // CSRF rejection: missing X-CSRF-Token header + // ---------------------------------------------------------------- + response = await fetch(`${baseUrl}/api/conversations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: baseUrl, + Cookie: jar1.header, + }, + body: JSON.stringify({ title: 'Blocked' }), + }); + assert.equal(response.status, 403); + + // ---------------------------------------------------------------- + // CSRF rejection: wrong Origin + // ---------------------------------------------------------------- response = await fetch(`${baseUrl}/api/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: 'https://attacker.invalid', + 'X-CSRF-Token': csrf1, + Cookie: jar1.header, }, body: JSON.stringify({ title: 'Blocked' }), }); assert.equal(response.status, 403); - response = await fetch(`${baseUrl}/api/conversations`, { + // ---------------------------------------------------------------- + // Guest 1 creates a conversation + // ---------------------------------------------------------------- + response = await authedFetch(baseUrl, jar1, csrf1, '/api/conversations', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Origin: baseUrl, - }, + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, body: JSON.stringify({ title: 'First quest' }), }); assert.equal(response.status, 201); const created = await response.json(); assert.match(created.id, /^[0-9a-f-]{36}$/); - response = await fetch(`${baseUrl}/api/conversations/${created.id}`); + // ---------------------------------------------------------------- + // Guest 1 gets the conversation + // ---------------------------------------------------------------- + response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`); assert.equal(response.status, 200); let conversation = await response.json(); assert.equal(conversation.title, 'First quest'); assert.deepEqual(conversation.turns, []); - response = await fetch(`${baseUrl}/api/conversations/${created.id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Origin: baseUrl, + // ---------------------------------------------------------------- + // Guest 1 renames it + // ---------------------------------------------------------------- + response = await authedFetch( + baseUrl, jar1, csrf1, `/api/conversations/${created.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, + body: JSON.stringify({ title: 'Renamed quest' }), }, - body: JSON.stringify({ title: 'Renamed quest' }), - }); + ); assert.equal(response.status, 200); - response = await fetch( - `${baseUrl}/api/conversations/${created.id}/turns`, + // ---------------------------------------------------------------- + // Guest 2: a separate identity + // ---------------------------------------------------------------- + const jar2 = new CookieJar(); // fresh — no cookies + const csrf2 = await bootstrapSession(baseUrl, jar2); + + // Guest 2 cannot see Guest 1's conversation + response = await authedFetch(baseUrl, jar2, null, `/api/conversations/${created.id}`); + assert.equal(response.status, 404, 'Guest 2 must not see Guest 1 conversation'); + + // Guest 2 cannot rename Guest 1's conversation + response = await authedFetch( + baseUrl, jar2, csrf2, `/api/conversations/${created.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, + body: JSON.stringify({ title: 'Hijacked' }), + }, + ); + assert.equal(response.status, 404, 'Guest 2 rename must fail'); + + // Guest 2 cannot delete Guest 1's conversation + response = await authedFetch( + baseUrl, jar2, csrf2, `/api/conversations/${created.id}`, + { method: 'DELETE', headers: { Origin: baseUrl } }, + ); + assert.equal(response.status, 404, 'Guest 2 delete must fail'); + + // ---------------------------------------------------------------- + // Listing: Guest 1 sees their own conversations; Guest 2 sees none + // ---------------------------------------------------------------- + response = await authedFetch(baseUrl, jar1, null, '/api/conversations'); + assert.equal(response.status, 200); + let listData = await response.json(); + assert.ok(Array.isArray(listData.conversations)); + assert.equal(listData.conversations.length, 1); + assert.equal(listData.conversations[0].id, created.id); + assert.equal(listData.conversations[0].title, 'Renamed quest'); + + response = await authedFetch(baseUrl, jar2, null, '/api/conversations'); + assert.equal(response.status, 200); + listData = await response.json(); + assert.equal(listData.conversations.length, 0); + + // ---------------------------------------------------------------- + // Pagination: create more conversations and paginate + // ---------------------------------------------------------------- + const ids = [created.id]; + for (let i = 0; i < 4; i++) { + const r = await authedFetch( + baseUrl, jar1, csrf1, '/api/conversations', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, + body: JSON.stringify({ title: `Chat ${i + 2}` }), + }, + ); + assert.equal(r.status, 201); + const c = await r.json(); + ids.push(c.id); + } + // Page 1 + response = await authedFetch(baseUrl, jar1, null, '/api/conversations?limit=3'); + assert.equal(response.status, 200); + const page1 = await response.json(); + assert.equal(page1.conversations.length, 3); + assert.ok(page1.cursor, 'cursor should be present'); + // Page 2 using cursor + response = await authedFetch( + baseUrl, jar1, null, `/api/conversations?cursor=${page1.cursor}&limit=3`, + ); + assert.equal(response.status, 200); + const page2 = await response.json(); + assert.ok(page2.conversations.length >= 1 && page2.conversations.length <= 2); + // IDs must not overlap between pages + const page1Ids = new Set(page1.conversations.map(c => c.id)); + const page2Ids = page2.conversations.map(c => c.id); + for (const id of page2Ids) assert.ok(!page1Ids.has(id), 'page 2 must not repeat page 1 IDs'); + + // ---------------------------------------------------------------- + // Streaming turn (existing mock inference path) + // ---------------------------------------------------------------- + response = await authedFetch( + baseUrl, jar1, csrf1, + `/api/conversations/${created.id}/turns`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Origin: baseUrl, - }, + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, body: JSON.stringify({ prompt: 'Hello Epi' }), }, ); @@ -157,7 +377,22 @@ assert.match(stream, /event: assistant\.usage/); assert.match(stream, /event: turn\.done/); - response = await fetch(`${baseUrl}/api/conversations/${created.id}`); + // Guest 2 cannot start a turn on Guest 1's conversation + response = await authedFetch( + baseUrl, jar2, csrf2, + `/api/conversations/${created.id}/turns`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, + body: JSON.stringify({ prompt: 'Intrude' }), + }, + ); + assert.equal(response.status, 404, 'Guest 2 must not turn on Guest 1 conversation'); + + // ---------------------------------------------------------------- + // Get after turn + // ---------------------------------------------------------------- + response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`); conversation = await response.json(); assert.equal(conversation.title, 'Renamed quest'); assert.equal(conversation.turns.length, 2); @@ -166,13 +401,318 @@ assert.equal(conversation.turns[1].input_tokens, 7); assert.equal(conversation.turns[1].output_tokens, 3); - response = await fetch(`${baseUrl}/api/conversations/${created.id}`, { - method: 'DELETE', - headers: { Origin: baseUrl }, - }); + // Listing should show last_message_preview + response = await authedFetch(baseUrl, jar1, null, '/api/conversations'); + listData = await response.json(); + const listedConv = listData.conversations.find(c => c.id === created.id); + assert.ok(listedConv, 'created conv must appear in listing'); + assert.ok(typeof listedConv.turn_count === 'number'); + assert.ok(typeof listedConv.last_message_preview === 'string'); + + // ---------------------------------------------------------------- + // Delete + // ---------------------------------------------------------------- + response = await authedFetch( + baseUrl, jar1, csrf1, `/api/conversations/${created.id}`, + { method: 'DELETE', headers: { Origin: baseUrl } }, + ); assert.equal(response.status, 204); - response = await fetch(`${baseUrl}/api/conversations/${created.id}`); + response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`); assert.equal(response.status, 404); + + // ---------------------------------------------------------------- + // Claim: POST /api/conversations/claim (body-based, ID not in URL) + // Uses a dedicated server with a pre-bootstrapped admin user. + // ---------------------------------------------------------------- + { + const claimPort = await findFreePort(); + const claimBase = `http://127.0.0.1:${claimPort}`; + const claimTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claim-test-')); + const claimDb = path.join(claimTmpDir, 'claim.db'); + const claimLogs = []; + // Pre-computed scrypt hash of "TestPass123!" + const claimHash = + 'zenbu-scrypt$v=1$N=32768$r=8$p=1$cd915bb57924094c3c53be9e8e05fcf4$' + + '15998394fca01b498124ca96c6da4e78f271b1d52043a5344e1a5ee7f92c5332'; + const claimServer = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { + ...process.env, + MRJUNEJUNE_PORT: claimPort, + MRJUNEJUNE_DB_PATH: claimDb, + MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar, + MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar, + AUTH_BOOTSTRAP_USERNAME: 'claimadmin', + AUTH_BOOTSTRAP_PASSWORD_HASH: claimHash, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + claimServer.stdout.on('data', c => claimLogs.push(c.toString())); + claimServer.stderr.on('data', c => claimLogs.push(c.toString())); + try { + await waitForServer(claimServer, claimBase, claimLogs); + + // Guest creates a conversation + const guestJarC = new CookieJar(); + const guestCsrfC = await bootstrapSession(claimBase, guestJarC); + const guestConvR = await authedFetch(claimBase, guestJarC, guestCsrfC, '/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: claimBase }, + body: JSON.stringify({ title: 'Legacy conv' }), + }); + assert.equal(guestConvR.status, 201); + const guestConv = await guestConvR.json(); + const legacyId = guestConv.id; + + // Old /:id/claim route must be gone (404) + const oldRouteR = await authedFetch(claimBase, guestJarC, guestCsrfC, + `/api/conversations/${legacyId}/claim`, + { method: 'POST', headers: { Origin: claimBase } }); + assert.ok( + oldRouteR.status === 404 || oldRouteR.status === 405, + `Old claim route must be absent, got ${oldRouteR.status}`, + ); + + // Guest claim attempt must be 403 + const guestClaimR = await authedFetch(claimBase, guestJarC, guestCsrfC, + '/api/conversations/claim', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: claimBase }, + body: JSON.stringify({ conversationId: legacyId }), + }); + assert.equal(guestClaimR.status, 403, 'Guest must not claim'); + + // Log in as the bootstrapped admin user + const userJarC = new CookieJar(); + const userCsrfC1 = await bootstrapSession(claimBase, userJarC); + const loginR = await authedFetch(claimBase, userJarC, userCsrfC1, '/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: claimBase }, + body: JSON.stringify({ + username: 'claimadmin', + password: 'TestPass123!', + csrfToken: userCsrfC1, + }), + }); + assert.equal(loginR.status, 200, `Login must succeed, got ${loginR.status}`); + const userCsrfC2 = await bootstrapSession(claimBase, userJarC); + + // A user cannot claim a conversation owned by a different guest. + const claimR = await authedFetch(claimBase, userJarC, userCsrfC2, + '/api/conversations/claim', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: claimBase }, + body: JSON.stringify({ conversationId: legacyId }), + }); + assert.equal(claimR.status, 409, `Guest-owned claim must fail, got ${claimR.status}`); + + // The conversation remains isolated from the unrelated user. + const ownedR = await authedFetch(claimBase, userJarC, null, `/api/conversations/${legacyId}`); + assert.equal(ownedR.status, 404, 'User must not see another guest conversation'); + + // Missing conversationId returns 400 + const badR = await authedFetch(claimBase, userJarC, userCsrfC2, + '/api/conversations/claim', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: claimBase }, + body: JSON.stringify({}), + }); + assert.equal(badR.status, 400, 'Missing ID must return 400'); + + } finally { + await stopProcess(claimServer); + fs.rmSync(claimTmpDir, { recursive: true, force: true }); + } + } + + // ---------------------------------------------------------------- + // Guest quota: session endpoint returns quota object for guest + // ---------------------------------------------------------------- + { + const jarQ = new CookieJar(); + const csrfQ = await bootstrapSession(baseUrl, jarQ); + // Session endpoint must return a quota object (not null) for guests + const sessionR = await authedFetch(baseUrl, jarQ, null, '/api/auth/session'); + assert.equal(sessionR.status, 200); + const sessionData = await sessionR.json(); + assert.equal(sessionData.kind, 'guest'); + assert.ok(sessionData.quota !== null && typeof sessionData.quota === 'object', + 'guest session must have non-null quota'); + assert.ok(typeof sessionData.quota.turnsLimit === 'number', + 'quota.turnsLimit must be a number'); + assert.ok(typeof sessionData.quota.turnsUsed === 'number', + 'quota.turnsUsed must be a number'); + assert.ok(typeof sessionData.quota.turnsRemaining === 'number', + 'quota.turnsRemaining must be a number'); + assert.ok(typeof sessionData.quota.outputTokensLimit === 'number', + 'quota.outputTokensLimit must be a number'); + assert.ok(typeof sessionData.quota.outputTokensUsed === 'number', + 'quota.outputTokensUsed must be a number'); + assert.ok(typeof sessionData.quota.outputTokensReserved === 'number', + 'quota.outputTokensReserved must be a number'); + assert.ok(typeof sessionData.quota.outputTokensRemaining === 'number', + 'quota.outputTokensRemaining must be a number'); + assert.ok(typeof sessionData.quota.resetsAt === 'number', + 'quota.resetsAt must be a number (unix timestamp)'); + assert.ok(sessionData.quota.resetsAt > Date.now() / 1000, + 'quota.resetsAt must be in the future'); + assert.equal(sessionData.quota.turnsUsed, 0, + 'fresh guest must have 0 turns used'); + assert.equal(sessionData.quota.turnsLimit, 10, + 'default turns limit must be 10'); + assert.equal(sessionData.quota.turnsRemaining, 10, + 'fresh guest must have full turns remaining'); + } + + // ---------------------------------------------------------------- + // Guest quota: quota decrements after a successful turn + // ---------------------------------------------------------------- + { + const jarQ2 = new CookieJar(); + const csrfQ2 = await bootstrapSession(baseUrl, jarQ2); + + // Create a conversation + const convR = await authedFetch(baseUrl, jarQ2, csrfQ2, '/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, + body: JSON.stringify({ title: 'Quota test' }), + }); + assert.equal(convR.status, 201); + const convData = await convR.json(); + + // Do a turn + const turnR = await authedFetch( + baseUrl, jarQ2, csrfQ2, + `/api/conversations/${convData.id}/turns`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseUrl }, + body: JSON.stringify({ prompt: 'quota turn' }), + }, + ); + assert.equal(turnR.status, 200, 'quota turn must succeed'); + // Drain the stream + await turnR.text(); + + // Session must now show 1 turn used + const sessR2 = await authedFetch(baseUrl, jarQ2, null, '/api/auth/session'); + assert.equal(sessR2.status, 200); + const sessData2 = await sessR2.json(); + assert.ok(sessData2.quota !== null); + assert.equal(sessData2.quota.turnsUsed, 1, + 'turnsUsed must be 1 after one successful turn'); + assert.equal(sessData2.quota.turnsRemaining, 9, + 'turnsRemaining must be 9 after one turn'); + assert.ok(sessData2.quota.outputTokensUsed > 0, + 'outputTokensUsed must be > 0 after a turn'); + } + + // ---------------------------------------------------------------- + // Guest quota: 429 when turns are exhausted (limit=1 via env) + // ---------------------------------------------------------------- + // Note: The main server is started with default limit (10 turns). This test + // exhausts the remaining turns by doing 9 more turns on a fresh guest, then + // verifies the next attempt returns 429. + // We use a separate small-limit server for this test to keep the suite fast. + { + const portQ = await findFreePort(); + const tempDirQ = fs.mkdtempSync( + path.join(os.tmpdir(), 'quota-limit-'), + ); + const dbQ = path.join(tempDirQ, 'q.db'); + const logsQ = []; + const serverQ = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { + ...process.env, + MRJUNEJUNE_PORT: portQ, + MRJUNEJUNE_DB_PATH: dbQ, + MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar, + MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar, + MRJUNEJUNE_ALLOW_GUEST_INFERENCE: '1', + AUTH_GUEST_DAILY_TURNS: '1', + AUTH_GUEST_DAILY_OUTPUT_TOKENS: '10000', + AUTH_GUEST_REQUEST_OUTPUT_TOKENS: '100', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + serverQ.stdout.on('data', c => logsQ.push(c.toString())); + serverQ.stderr.on('data', c => logsQ.push(c.toString())); + try { + await waitForServer(serverQ, `http://127.0.0.1:${portQ}`, logsQ); + const baseQ = `http://127.0.0.1:${portQ}`; + + const jarL = new CookieJar(); + const csrfL = await bootstrapSession(baseQ, jarL); + + // Create a conversation + const convL = await authedFetch(baseQ, jarL, csrfL, '/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseQ }, + body: JSON.stringify({ title: 'Limit test' }), + }); + assert.equal(convL.status, 201); + const convLData = await convL.json(); + + // First turn must succeed (limit=1) + const turn1 = await authedFetch( + baseQ, jarL, csrfL, + `/api/conversations/${convLData.id}/turns`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseQ }, + body: JSON.stringify({ prompt: 'hello' }), + }, + ); + assert.equal(turn1.status, 200, 'first turn within limit must succeed'); + await turn1.text(); + + // Create a second conversation for second turn + const convL2 = await authedFetch(baseQ, jarL, csrfL, '/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseQ }, + body: JSON.stringify({ title: 'Limit test 2' }), + }); + assert.equal(convL2.status, 201); + const convL2Data = await convL2.json(); + + // Second turn must be rejected with 429 + const turn2 = await authedFetch( + baseQ, jarL, csrfL, + `/api/conversations/${convL2Data.id}/turns`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: baseQ }, + body: JSON.stringify({ prompt: 'should fail' }), + }, + ); + assert.equal(turn2.status, 429, 'second turn must be 429 when turns exhausted'); + const errorData = await turn2.json(); + assert.ok(errorData.error, '429 must have error field'); + assert.equal(errorData.error.code, 'guest_quota_turns_exhausted', + '429 code must be guest_quota_turns_exhausted'); + assert.ok(errorData.error.quota, '429 must include quota details'); + assert.equal(errorData.error.quota.turnsRemaining, 0, + 'turnsRemaining must be 0 in 429 payload'); + assert.ok(typeof errorData.error.quota.resetsAt === 'number', + '429 quota must include resetsAt'); + + // Verify no turn was persisted for the rejected request + const convL2Get = await authedFetch(baseQ, jarL, null, + `/api/conversations/${convL2Data.id}`); + const convL2Detail = await convL2Get.json(); + assert.equal(convL2Detail.turns.length, 0, + 'no turn must be persisted when quota is denied'); + + } finally { + await stopProcess(serverQ); + fs.rmSync(tempDirQ, { recursive: true, force: true }); + } + } + } finally { await stopProcess(server); fs.rmSync(tempDirectory, { recursive: true, force: true });