Mercurial
view mrjunejune/test/conversation_api_test.js @ 265:056790c4fb0d
add role-aware Epi assistant prompts
Add verified June knowledge, guest/member/admin Copilot profiles, profile-isolated session recovery, animated Epi greetings, and a single authoritative runtime config workflow for inference.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 10:50:30 -0700 |
| parents | 04fee26ecce0 |
| children |
line wrap: on
line source
const assert = require('node:assert/strict'); const fs = require('node:fs'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const RUNFILES = process.env.JS_BINARY__RUNFILES; const WORKSPACE = process.env.JS_BINARY__WORKSPACE; const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); function findFreePort() { return new Promise((resolve, reject) => { const socket = net.createServer(); socket.once('error', reject); socket.listen(0, '127.0.0.1', () => { const address = socket.address(); socket.close(error => { if (error) reject(error); else resolve(String(address.port)); }); }); }); } async function waitForServer(child, baseUrl, logs) { const deadline = Date.now() + 15000; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Server exited with ${child.exitCode}\n${logs.join('')}`); } try { const response = await fetch(baseUrl); if (response.ok) return; } catch { // Keep waiting for startup. } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error(`Server startup timed out\n${logs.join('')}`); } async function stopProcess(child) { if (!child || child.exitCode !== null) return; child.kill('SIGTERM'); await new Promise(resolve => { const timer = setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); }, 3000); child.once('exit', () => { clearTimeout(timer); resolve(); }); }); } /* ------------------------------------------------------------------ */ /* 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); const serverBinary = path.join( runfilesWorkspace, 'mrjunejune/mrjunejune_server', ); const fakeSidecar = path.join( runfilesWorkspace, 'mrjunejune/test/inference_bridge_fake_sidecar', ); const tempDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'mrjunejune-conversation-api-'), ); const databasePath = path.join(tempDirectory, 'conversations.db'); const port = await findFreePort(); const baseUrl = `http://127.0.0.1:${port}`; const logs = []; let server; try { server = spawn(serverBinary, [], { cwd: runfilesWorkspace, env: { ...process.env, MRJUNEJUNE_PORT: port, MRJUNEJUNE_DB_PATH: databasePath, MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar, MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar, 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); // ---------------------------------------------------------------- // Guest 1 creates a conversation // ---------------------------------------------------------------- response = await authedFetch(baseUrl, jar1, csrf1, '/api/conversations', { method: 'POST', 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}$/); // ---------------------------------------------------------------- // 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, []); // ---------------------------------------------------------------- // 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' }), }, ); assert.equal(response.status, 200); // ---------------------------------------------------------------- // 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 }, body: JSON.stringify({ prompt: 'Hello Epi' }), }, ); assert.equal(response.status, 200); assert.match( response.headers.get('content-type') || '', /^text\/event-stream/, ); const stream = await response.text(); assert.match(stream, /event: turn\.accepted/); assert.match(stream, /event: tool\.started/); assert.match( stream, /"tool":\{"name":"mock-search","prompt_profile":"public_visitor"\}/, ); assert.match(stream, /event: assistant\.delta/); assert.match(stream, /hello \\"traveler\\"\\\\path/); assert.match(stream, /event: assistant\.completed/); assert.match(stream, /event: assistant\.usage/); assert.match(stream, /event: turn\.done/); // 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); assert.equal(conversation.turns[0].content, 'Hello Epi'); assert.equal(conversation.turns[1].content, 'hello traveler'); assert.equal(conversation.turns[1].input_tokens, 7); assert.equal(conversation.turns[1].output_tokens, 3); // 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 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); const assertTurnProfile = async (jar, csrf, expectedProfile, title) => { const createResponse = await authedFetch( claimBase, jar, csrf, '/api/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: claimBase }, body: JSON.stringify({ title }), }, ); assert.equal(createResponse.status, 201); const createdConversation = await createResponse.json(); const turnResponse = await authedFetch( claimBase, jar, csrf, `/api/conversations/${createdConversation.id}/turns`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: claimBase }, body: JSON.stringify({ prompt: 'profile check' }), }, ); assert.equal(turnResponse.status, 200); assert.match( await turnResponse.text(), new RegExp(`"prompt_profile":"${expectedProfile}"`), ); }; await assertTurnProfile( userJarC, userCsrfC2, 'june_admin', 'Admin profile', ); const createMemberR = await authedFetch( claimBase, userJarC, userCsrfC2, '/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: claimBase }, body: JSON.stringify({ username: 'invitedfriend', temporaryPassword: 'InvitedTempPass123!', role: 'member', }), }, ); assert.equal(createMemberR.status, 201); const memberJar = new CookieJar(); const memberGuestCsrf = await bootstrapSession(claimBase, memberJar); const memberLoginR = await authedFetch( claimBase, memberJar, memberGuestCsrf, '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: claimBase }, body: JSON.stringify({ username: 'invitedfriend', password: 'InvitedTempPass123!', csrfToken: memberGuestCsrf, }), }, ); assert.equal(memberLoginR.status, 200); const memberLoginData = await memberLoginR.json(); assert.equal(memberLoginData.mustChangePassword, true); const memberPasswordCsrf = await bootstrapSession(claimBase, memberJar); const memberPasswordR = await authedFetch( claimBase, memberJar, memberPasswordCsrf, '/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: claimBase }, body: JSON.stringify({ currentPassword: 'InvitedTempPass123!', newPassword: 'InvitedPermanentPass123!', csrfToken: memberPasswordCsrf, }), }, ); assert.equal(memberPasswordR.status, 200); const memberCsrf = await bootstrapSession(claimBase, memberJar); await assertTurnProfile( memberJar, memberCsrf, 'invited_friend', 'Invited profile', ); // 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 }); } })().catch(error => { console.error(error.stack || error); process.exitCode = 1; });