comparison 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
comparison
equal deleted inserted replaced
263:ee04e4e69fed 264:04fee26ecce0
51 clearTimeout(timer); 51 clearTimeout(timer);
52 resolve(); 52 resolve();
53 }); 53 });
54 }); 54 });
55 } 55 }
56
57 /* ------------------------------------------------------------------ */
58 /* Cookie jar: tracks Set-Cookie headers across responses */
59 /* ------------------------------------------------------------------ */
60
61 class CookieJar {
62 constructor() {
63 this._cookies = new Map();
64 }
65
66 /** Update jar from a Set-Cookie header value (single directive). */
67 updateFromDirective(directive) {
68 if (!directive) return;
69 const firstPart = directive.split(';')[0].trim();
70 const eqIdx = firstPart.indexOf('=');
71 if (eqIdx < 0) return;
72 const name = firstPart.slice(0, eqIdx).trim();
73 const value = firstPart.slice(eqIdx + 1).trim();
74 if (!name) return;
75 // A max-age of -1 or a "deleted" value clears the cookie.
76 if (/max-age\s*=\s*-?0/i.test(directive) || value === '' || value.toLowerCase() === 'deleted') {
77 this._cookies.delete(name);
78 } else {
79 this._cookies.set(name, value);
80 }
81 }
82
83 /** Update jar from a response (handles multiple Set-Cookie headers). */
84 updateFromResponse(response) {
85 let setCookies;
86 try {
87 setCookies = response.headers.getSetCookie();
88 } catch {
89 // Fallback for older Node versions
90 const raw = response.headers.get('set-cookie') || '';
91 setCookies = raw ? raw.split(',\n').map(s => s.trim()).filter(Boolean) : [];
92 }
93 for (const directive of setCookies) {
94 this.updateFromDirective(directive);
95 }
96 }
97
98 get header() {
99 return [...this._cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
100 }
101
102 clone() {
103 const jar = new CookieJar();
104 for (const [k, v] of this._cookies) jar._cookies.set(k, v);
105 return jar;
106 }
107 }
108
109 /* ------------------------------------------------------------------ */
110 /* Session bootstrap: GET /api/auth/session → csrfToken + cookie jar */
111 /* ------------------------------------------------------------------ */
112
113 async function bootstrapSession(baseUrl, jar) {
114 const headers = {};
115 if (jar.header) headers['Cookie'] = jar.header;
116 const response = await fetch(`${baseUrl}/api/auth/session`, { headers });
117 assert.equal(response.status, 200);
118 jar.updateFromResponse(response);
119 const data = await response.json();
120 assert.ok(data.csrfToken, 'session must return csrfToken');
121 return data.csrfToken;
122 }
123
124 /* ------------------------------------------------------------------ */
125 /* Fetch helper: adds Cookie + X-CSRF-Token headers */
126 /* ------------------------------------------------------------------ */
127
128 async function authedFetch(baseUrl, jar, csrfToken, path, options = {}) {
129 const headers = { ...(options.headers || {}) };
130 if (jar.header) headers['Cookie'] = jar.header;
131 if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
132 const response = await fetch(`${baseUrl}${path}`, { ...options, headers });
133 jar.updateFromResponse(response);
134 return response;
135 }
136
137 /* ------------------------------------------------------------------ */
138 /* Main test suite */
139 /* ------------------------------------------------------------------ */
56 140
57 (async () => { 141 (async () => {
58 assert.ok(RUNFILES); 142 assert.ok(RUNFILES);
59 assert.ok(WORKSPACE); 143 assert.ok(WORKSPACE);
60 const serverBinary = path.join( 144 const serverBinary = path.join(
82 MRJUNEJUNE_PORT: port, 166 MRJUNEJUNE_PORT: port,
83 MRJUNEJUNE_DB_PATH: databasePath, 167 MRJUNEJUNE_DB_PATH: databasePath,
84 MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar, 168 MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar,
85 MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar, 169 MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar,
86 MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE: '1', 170 MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE: '1',
171 MRJUNEJUNE_ALLOW_GUEST_INFERENCE: '1',
87 }, 172 },
88 stdio: ['ignore', 'pipe', 'pipe'], 173 stdio: ['ignore', 'pipe', 'pipe'],
89 }); 174 });
90 server.stdout.on('data', chunk => logs.push(chunk.toString())); 175 server.stdout.on('data', chunk => logs.push(chunk.toString()));
91 server.stderr.on('data', chunk => logs.push(chunk.toString())); 176 server.stderr.on('data', chunk => logs.push(chunk.toString()));
92 await waitForServer(server, baseUrl, logs); 177 await waitForServer(server, baseUrl, logs);
178
179 // ----------------------------------------------------------------
180 // Inference health check
181 // ----------------------------------------------------------------
93 let response = await fetch(`${baseUrl}/api/inference/health`); 182 let response = await fetch(`${baseUrl}/api/inference/health`);
94 assert.equal(response.status, 200); 183 assert.equal(response.status, 200);
95 184
185 // ----------------------------------------------------------------
186 // Task 2: mutation without any session returns 401 (no guest created)
187 // ----------------------------------------------------------------
188 response = await fetch(`${baseUrl}/api/conversations`, {
189 method: 'POST',
190 headers: {
191 'Content-Type': 'application/json',
192 Origin: baseUrl,
193 'X-CSRF-Token': 'some-token',
194 },
195 body: JSON.stringify({ title: 'No session' }),
196 });
197 assert.equal(response.status, 401, 'mutation without session must return 401');
198 // Ensure no Set-Cookie guest header was emitted (no guest row written)
199 const setCookieNoSession = response.headers.get('set-cookie') || '';
200 assert.ok(
201 !setCookieNoSession.includes('mjj_guest'),
202 'mutation without session must not set guest cookie',
203 );
204
205 // ----------------------------------------------------------------
206 // Guest 1: bootstrap session
207 // ----------------------------------------------------------------
208 const jar1 = new CookieJar();
209 const csrf1 = await bootstrapSession(baseUrl, jar1);
210
211 // ----------------------------------------------------------------
212 // CSRF rejection: missing X-CSRF-Token header
213 // ----------------------------------------------------------------
214 response = await fetch(`${baseUrl}/api/conversations`, {
215 method: 'POST',
216 headers: {
217 'Content-Type': 'application/json',
218 Origin: baseUrl,
219 Cookie: jar1.header,
220 },
221 body: JSON.stringify({ title: 'Blocked' }),
222 });
223 assert.equal(response.status, 403);
224
225 // ----------------------------------------------------------------
226 // CSRF rejection: wrong Origin
227 // ----------------------------------------------------------------
96 response = await fetch(`${baseUrl}/api/conversations`, { 228 response = await fetch(`${baseUrl}/api/conversations`, {
97 method: 'POST', 229 method: 'POST',
98 headers: { 230 headers: {
99 'Content-Type': 'application/json', 231 'Content-Type': 'application/json',
100 Origin: 'https://attacker.invalid', 232 Origin: 'https://attacker.invalid',
233 'X-CSRF-Token': csrf1,
234 Cookie: jar1.header,
101 }, 235 },
102 body: JSON.stringify({ title: 'Blocked' }), 236 body: JSON.stringify({ title: 'Blocked' }),
103 }); 237 });
104 assert.equal(response.status, 403); 238 assert.equal(response.status, 403);
105 239
106 response = await fetch(`${baseUrl}/api/conversations`, { 240 // ----------------------------------------------------------------
241 // Guest 1 creates a conversation
242 // ----------------------------------------------------------------
243 response = await authedFetch(baseUrl, jar1, csrf1, '/api/conversations', {
107 method: 'POST', 244 method: 'POST',
108 headers: { 245 headers: { 'Content-Type': 'application/json', Origin: baseUrl },
109 'Content-Type': 'application/json',
110 Origin: baseUrl,
111 },
112 body: JSON.stringify({ title: 'First quest' }), 246 body: JSON.stringify({ title: 'First quest' }),
113 }); 247 });
114 assert.equal(response.status, 201); 248 assert.equal(response.status, 201);
115 const created = await response.json(); 249 const created = await response.json();
116 assert.match(created.id, /^[0-9a-f-]{36}$/); 250 assert.match(created.id, /^[0-9a-f-]{36}$/);
117 251
118 response = await fetch(`${baseUrl}/api/conversations/${created.id}`); 252 // ----------------------------------------------------------------
253 // Guest 1 gets the conversation
254 // ----------------------------------------------------------------
255 response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`);
119 assert.equal(response.status, 200); 256 assert.equal(response.status, 200);
120 let conversation = await response.json(); 257 let conversation = await response.json();
121 assert.equal(conversation.title, 'First quest'); 258 assert.equal(conversation.title, 'First quest');
122 assert.deepEqual(conversation.turns, []); 259 assert.deepEqual(conversation.turns, []);
123 260
124 response = await fetch(`${baseUrl}/api/conversations/${created.id}`, { 261 // ----------------------------------------------------------------
125 method: 'PATCH', 262 // Guest 1 renames it
126 headers: { 263 // ----------------------------------------------------------------
127 'Content-Type': 'application/json', 264 response = await authedFetch(
128 Origin: baseUrl, 265 baseUrl, jar1, csrf1, `/api/conversations/${created.id}`,
266 {
267 method: 'PATCH',
268 headers: { 'Content-Type': 'application/json', Origin: baseUrl },
269 body: JSON.stringify({ title: 'Renamed quest' }),
129 }, 270 },
130 body: JSON.stringify({ title: 'Renamed quest' }), 271 );
131 });
132 assert.equal(response.status, 200); 272 assert.equal(response.status, 200);
133 273
134 response = await fetch( 274 // ----------------------------------------------------------------
135 `${baseUrl}/api/conversations/${created.id}/turns`, 275 // Guest 2: a separate identity
276 // ----------------------------------------------------------------
277 const jar2 = new CookieJar(); // fresh — no cookies
278 const csrf2 = await bootstrapSession(baseUrl, jar2);
279
280 // Guest 2 cannot see Guest 1's conversation
281 response = await authedFetch(baseUrl, jar2, null, `/api/conversations/${created.id}`);
282 assert.equal(response.status, 404, 'Guest 2 must not see Guest 1 conversation');
283
284 // Guest 2 cannot rename Guest 1's conversation
285 response = await authedFetch(
286 baseUrl, jar2, csrf2, `/api/conversations/${created.id}`,
287 {
288 method: 'PATCH',
289 headers: { 'Content-Type': 'application/json', Origin: baseUrl },
290 body: JSON.stringify({ title: 'Hijacked' }),
291 },
292 );
293 assert.equal(response.status, 404, 'Guest 2 rename must fail');
294
295 // Guest 2 cannot delete Guest 1's conversation
296 response = await authedFetch(
297 baseUrl, jar2, csrf2, `/api/conversations/${created.id}`,
298 { method: 'DELETE', headers: { Origin: baseUrl } },
299 );
300 assert.equal(response.status, 404, 'Guest 2 delete must fail');
301
302 // ----------------------------------------------------------------
303 // Listing: Guest 1 sees their own conversations; Guest 2 sees none
304 // ----------------------------------------------------------------
305 response = await authedFetch(baseUrl, jar1, null, '/api/conversations');
306 assert.equal(response.status, 200);
307 let listData = await response.json();
308 assert.ok(Array.isArray(listData.conversations));
309 assert.equal(listData.conversations.length, 1);
310 assert.equal(listData.conversations[0].id, created.id);
311 assert.equal(listData.conversations[0].title, 'Renamed quest');
312
313 response = await authedFetch(baseUrl, jar2, null, '/api/conversations');
314 assert.equal(response.status, 200);
315 listData = await response.json();
316 assert.equal(listData.conversations.length, 0);
317
318 // ----------------------------------------------------------------
319 // Pagination: create more conversations and paginate
320 // ----------------------------------------------------------------
321 const ids = [created.id];
322 for (let i = 0; i < 4; i++) {
323 const r = await authedFetch(
324 baseUrl, jar1, csrf1, '/api/conversations',
325 {
326 method: 'POST',
327 headers: { 'Content-Type': 'application/json', Origin: baseUrl },
328 body: JSON.stringify({ title: `Chat ${i + 2}` }),
329 },
330 );
331 assert.equal(r.status, 201);
332 const c = await r.json();
333 ids.push(c.id);
334 }
335 // Page 1
336 response = await authedFetch(baseUrl, jar1, null, '/api/conversations?limit=3');
337 assert.equal(response.status, 200);
338 const page1 = await response.json();
339 assert.equal(page1.conversations.length, 3);
340 assert.ok(page1.cursor, 'cursor should be present');
341 // Page 2 using cursor
342 response = await authedFetch(
343 baseUrl, jar1, null, `/api/conversations?cursor=${page1.cursor}&limit=3`,
344 );
345 assert.equal(response.status, 200);
346 const page2 = await response.json();
347 assert.ok(page2.conversations.length >= 1 && page2.conversations.length <= 2);
348 // IDs must not overlap between pages
349 const page1Ids = new Set(page1.conversations.map(c => c.id));
350 const page2Ids = page2.conversations.map(c => c.id);
351 for (const id of page2Ids) assert.ok(!page1Ids.has(id), 'page 2 must not repeat page 1 IDs');
352
353 // ----------------------------------------------------------------
354 // Streaming turn (existing mock inference path)
355 // ----------------------------------------------------------------
356 response = await authedFetch(
357 baseUrl, jar1, csrf1,
358 `/api/conversations/${created.id}/turns`,
136 { 359 {
137 method: 'POST', 360 method: 'POST',
138 headers: { 361 headers: { 'Content-Type': 'application/json', Origin: baseUrl },
139 'Content-Type': 'application/json',
140 Origin: baseUrl,
141 },
142 body: JSON.stringify({ prompt: 'Hello Epi' }), 362 body: JSON.stringify({ prompt: 'Hello Epi' }),
143 }, 363 },
144 ); 364 );
145 assert.equal(response.status, 200); 365 assert.equal(response.status, 200);
146 assert.match( 366 assert.match(
155 assert.match(stream, /hello \\"traveler\\"\\\\path/); 375 assert.match(stream, /hello \\"traveler\\"\\\\path/);
156 assert.match(stream, /event: assistant\.completed/); 376 assert.match(stream, /event: assistant\.completed/);
157 assert.match(stream, /event: assistant\.usage/); 377 assert.match(stream, /event: assistant\.usage/);
158 assert.match(stream, /event: turn\.done/); 378 assert.match(stream, /event: turn\.done/);
159 379
160 response = await fetch(`${baseUrl}/api/conversations/${created.id}`); 380 // Guest 2 cannot start a turn on Guest 1's conversation
381 response = await authedFetch(
382 baseUrl, jar2, csrf2,
383 `/api/conversations/${created.id}/turns`,
384 {
385 method: 'POST',
386 headers: { 'Content-Type': 'application/json', Origin: baseUrl },
387 body: JSON.stringify({ prompt: 'Intrude' }),
388 },
389 );
390 assert.equal(response.status, 404, 'Guest 2 must not turn on Guest 1 conversation');
391
392 // ----------------------------------------------------------------
393 // Get after turn
394 // ----------------------------------------------------------------
395 response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`);
161 conversation = await response.json(); 396 conversation = await response.json();
162 assert.equal(conversation.title, 'Renamed quest'); 397 assert.equal(conversation.title, 'Renamed quest');
163 assert.equal(conversation.turns.length, 2); 398 assert.equal(conversation.turns.length, 2);
164 assert.equal(conversation.turns[0].content, 'Hello Epi'); 399 assert.equal(conversation.turns[0].content, 'Hello Epi');
165 assert.equal(conversation.turns[1].content, 'hello traveler'); 400 assert.equal(conversation.turns[1].content, 'hello traveler');
166 assert.equal(conversation.turns[1].input_tokens, 7); 401 assert.equal(conversation.turns[1].input_tokens, 7);
167 assert.equal(conversation.turns[1].output_tokens, 3); 402 assert.equal(conversation.turns[1].output_tokens, 3);
168 403
169 response = await fetch(`${baseUrl}/api/conversations/${created.id}`, { 404 // Listing should show last_message_preview
170 method: 'DELETE', 405 response = await authedFetch(baseUrl, jar1, null, '/api/conversations');
171 headers: { Origin: baseUrl }, 406 listData = await response.json();
172 }); 407 const listedConv = listData.conversations.find(c => c.id === created.id);
408 assert.ok(listedConv, 'created conv must appear in listing');
409 assert.ok(typeof listedConv.turn_count === 'number');
410 assert.ok(typeof listedConv.last_message_preview === 'string');
411
412 // ----------------------------------------------------------------
413 // Delete
414 // ----------------------------------------------------------------
415 response = await authedFetch(
416 baseUrl, jar1, csrf1, `/api/conversations/${created.id}`,
417 { method: 'DELETE', headers: { Origin: baseUrl } },
418 );
173 assert.equal(response.status, 204); 419 assert.equal(response.status, 204);
174 response = await fetch(`${baseUrl}/api/conversations/${created.id}`); 420 response = await authedFetch(baseUrl, jar1, null, `/api/conversations/${created.id}`);
175 assert.equal(response.status, 404); 421 assert.equal(response.status, 404);
422
423 // ----------------------------------------------------------------
424 // Claim: POST /api/conversations/claim (body-based, ID not in URL)
425 // Uses a dedicated server with a pre-bootstrapped admin user.
426 // ----------------------------------------------------------------
427 {
428 const claimPort = await findFreePort();
429 const claimBase = `http://127.0.0.1:${claimPort}`;
430 const claimTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claim-test-'));
431 const claimDb = path.join(claimTmpDir, 'claim.db');
432 const claimLogs = [];
433 // Pre-computed scrypt hash of "TestPass123!"
434 const claimHash =
435 'zenbu-scrypt$v=1$N=32768$r=8$p=1$cd915bb57924094c3c53be9e8e05fcf4$' +
436 '15998394fca01b498124ca96c6da4e78f271b1d52043a5344e1a5ee7f92c5332';
437 const claimServer = spawn(serverBinary, [], {
438 cwd: runfilesWorkspace,
439 env: {
440 ...process.env,
441 MRJUNEJUNE_PORT: claimPort,
442 MRJUNEJUNE_DB_PATH: claimDb,
443 MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar,
444 MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar,
445 AUTH_BOOTSTRAP_USERNAME: 'claimadmin',
446 AUTH_BOOTSTRAP_PASSWORD_HASH: claimHash,
447 },
448 stdio: ['ignore', 'pipe', 'pipe'],
449 });
450 claimServer.stdout.on('data', c => claimLogs.push(c.toString()));
451 claimServer.stderr.on('data', c => claimLogs.push(c.toString()));
452 try {
453 await waitForServer(claimServer, claimBase, claimLogs);
454
455 // Guest creates a conversation
456 const guestJarC = new CookieJar();
457 const guestCsrfC = await bootstrapSession(claimBase, guestJarC);
458 const guestConvR = await authedFetch(claimBase, guestJarC, guestCsrfC, '/api/conversations', {
459 method: 'POST',
460 headers: { 'Content-Type': 'application/json', Origin: claimBase },
461 body: JSON.stringify({ title: 'Legacy conv' }),
462 });
463 assert.equal(guestConvR.status, 201);
464 const guestConv = await guestConvR.json();
465 const legacyId = guestConv.id;
466
467 // Old /:id/claim route must be gone (404)
468 const oldRouteR = await authedFetch(claimBase, guestJarC, guestCsrfC,
469 `/api/conversations/${legacyId}/claim`,
470 { method: 'POST', headers: { Origin: claimBase } });
471 assert.ok(
472 oldRouteR.status === 404 || oldRouteR.status === 405,
473 `Old claim route must be absent, got ${oldRouteR.status}`,
474 );
475
476 // Guest claim attempt must be 403
477 const guestClaimR = await authedFetch(claimBase, guestJarC, guestCsrfC,
478 '/api/conversations/claim',
479 {
480 method: 'POST',
481 headers: { 'Content-Type': 'application/json', Origin: claimBase },
482 body: JSON.stringify({ conversationId: legacyId }),
483 });
484 assert.equal(guestClaimR.status, 403, 'Guest must not claim');
485
486 // Log in as the bootstrapped admin user
487 const userJarC = new CookieJar();
488 const userCsrfC1 = await bootstrapSession(claimBase, userJarC);
489 const loginR = await authedFetch(claimBase, userJarC, userCsrfC1, '/api/auth/login', {
490 method: 'POST',
491 headers: { 'Content-Type': 'application/json', Origin: claimBase },
492 body: JSON.stringify({
493 username: 'claimadmin',
494 password: 'TestPass123!',
495 csrfToken: userCsrfC1,
496 }),
497 });
498 assert.equal(loginR.status, 200, `Login must succeed, got ${loginR.status}`);
499 const userCsrfC2 = await bootstrapSession(claimBase, userJarC);
500
501 // A user cannot claim a conversation owned by a different guest.
502 const claimR = await authedFetch(claimBase, userJarC, userCsrfC2,
503 '/api/conversations/claim',
504 {
505 method: 'POST',
506 headers: { 'Content-Type': 'application/json', Origin: claimBase },
507 body: JSON.stringify({ conversationId: legacyId }),
508 });
509 assert.equal(claimR.status, 409, `Guest-owned claim must fail, got ${claimR.status}`);
510
511 // The conversation remains isolated from the unrelated user.
512 const ownedR = await authedFetch(claimBase, userJarC, null, `/api/conversations/${legacyId}`);
513 assert.equal(ownedR.status, 404, 'User must not see another guest conversation');
514
515 // Missing conversationId returns 400
516 const badR = await authedFetch(claimBase, userJarC, userCsrfC2,
517 '/api/conversations/claim',
518 {
519 method: 'POST',
520 headers: { 'Content-Type': 'application/json', Origin: claimBase },
521 body: JSON.stringify({}),
522 });
523 assert.equal(badR.status, 400, 'Missing ID must return 400');
524
525 } finally {
526 await stopProcess(claimServer);
527 fs.rmSync(claimTmpDir, { recursive: true, force: true });
528 }
529 }
530
531 // ----------------------------------------------------------------
532 // Guest quota: session endpoint returns quota object for guest
533 // ----------------------------------------------------------------
534 {
535 const jarQ = new CookieJar();
536 const csrfQ = await bootstrapSession(baseUrl, jarQ);
537 // Session endpoint must return a quota object (not null) for guests
538 const sessionR = await authedFetch(baseUrl, jarQ, null, '/api/auth/session');
539 assert.equal(sessionR.status, 200);
540 const sessionData = await sessionR.json();
541 assert.equal(sessionData.kind, 'guest');
542 assert.ok(sessionData.quota !== null && typeof sessionData.quota === 'object',
543 'guest session must have non-null quota');
544 assert.ok(typeof sessionData.quota.turnsLimit === 'number',
545 'quota.turnsLimit must be a number');
546 assert.ok(typeof sessionData.quota.turnsUsed === 'number',
547 'quota.turnsUsed must be a number');
548 assert.ok(typeof sessionData.quota.turnsRemaining === 'number',
549 'quota.turnsRemaining must be a number');
550 assert.ok(typeof sessionData.quota.outputTokensLimit === 'number',
551 'quota.outputTokensLimit must be a number');
552 assert.ok(typeof sessionData.quota.outputTokensUsed === 'number',
553 'quota.outputTokensUsed must be a number');
554 assert.ok(typeof sessionData.quota.outputTokensReserved === 'number',
555 'quota.outputTokensReserved must be a number');
556 assert.ok(typeof sessionData.quota.outputTokensRemaining === 'number',
557 'quota.outputTokensRemaining must be a number');
558 assert.ok(typeof sessionData.quota.resetsAt === 'number',
559 'quota.resetsAt must be a number (unix timestamp)');
560 assert.ok(sessionData.quota.resetsAt > Date.now() / 1000,
561 'quota.resetsAt must be in the future');
562 assert.equal(sessionData.quota.turnsUsed, 0,
563 'fresh guest must have 0 turns used');
564 assert.equal(sessionData.quota.turnsLimit, 10,
565 'default turns limit must be 10');
566 assert.equal(sessionData.quota.turnsRemaining, 10,
567 'fresh guest must have full turns remaining');
568 }
569
570 // ----------------------------------------------------------------
571 // Guest quota: quota decrements after a successful turn
572 // ----------------------------------------------------------------
573 {
574 const jarQ2 = new CookieJar();
575 const csrfQ2 = await bootstrapSession(baseUrl, jarQ2);
576
577 // Create a conversation
578 const convR = await authedFetch(baseUrl, jarQ2, csrfQ2, '/api/conversations', {
579 method: 'POST',
580 headers: { 'Content-Type': 'application/json', Origin: baseUrl },
581 body: JSON.stringify({ title: 'Quota test' }),
582 });
583 assert.equal(convR.status, 201);
584 const convData = await convR.json();
585
586 // Do a turn
587 const turnR = await authedFetch(
588 baseUrl, jarQ2, csrfQ2,
589 `/api/conversations/${convData.id}/turns`,
590 {
591 method: 'POST',
592 headers: { 'Content-Type': 'application/json', Origin: baseUrl },
593 body: JSON.stringify({ prompt: 'quota turn' }),
594 },
595 );
596 assert.equal(turnR.status, 200, 'quota turn must succeed');
597 // Drain the stream
598 await turnR.text();
599
600 // Session must now show 1 turn used
601 const sessR2 = await authedFetch(baseUrl, jarQ2, null, '/api/auth/session');
602 assert.equal(sessR2.status, 200);
603 const sessData2 = await sessR2.json();
604 assert.ok(sessData2.quota !== null);
605 assert.equal(sessData2.quota.turnsUsed, 1,
606 'turnsUsed must be 1 after one successful turn');
607 assert.equal(sessData2.quota.turnsRemaining, 9,
608 'turnsRemaining must be 9 after one turn');
609 assert.ok(sessData2.quota.outputTokensUsed > 0,
610 'outputTokensUsed must be > 0 after a turn');
611 }
612
613 // ----------------------------------------------------------------
614 // Guest quota: 429 when turns are exhausted (limit=1 via env)
615 // ----------------------------------------------------------------
616 // Note: The main server is started with default limit (10 turns). This test
617 // exhausts the remaining turns by doing 9 more turns on a fresh guest, then
618 // verifies the next attempt returns 429.
619 // We use a separate small-limit server for this test to keep the suite fast.
620 {
621 const portQ = await findFreePort();
622 const tempDirQ = fs.mkdtempSync(
623 path.join(os.tmpdir(), 'quota-limit-'),
624 );
625 const dbQ = path.join(tempDirQ, 'q.db');
626 const logsQ = [];
627 const serverQ = spawn(serverBinary, [], {
628 cwd: runfilesWorkspace,
629 env: {
630 ...process.env,
631 MRJUNEJUNE_PORT: portQ,
632 MRJUNEJUNE_DB_PATH: dbQ,
633 MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar,
634 MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar,
635 MRJUNEJUNE_ALLOW_GUEST_INFERENCE: '1',
636 AUTH_GUEST_DAILY_TURNS: '1',
637 AUTH_GUEST_DAILY_OUTPUT_TOKENS: '10000',
638 AUTH_GUEST_REQUEST_OUTPUT_TOKENS: '100',
639 },
640 stdio: ['ignore', 'pipe', 'pipe'],
641 });
642 serverQ.stdout.on('data', c => logsQ.push(c.toString()));
643 serverQ.stderr.on('data', c => logsQ.push(c.toString()));
644 try {
645 await waitForServer(serverQ, `http://127.0.0.1:${portQ}`, logsQ);
646 const baseQ = `http://127.0.0.1:${portQ}`;
647
648 const jarL = new CookieJar();
649 const csrfL = await bootstrapSession(baseQ, jarL);
650
651 // Create a conversation
652 const convL = await authedFetch(baseQ, jarL, csrfL, '/api/conversations', {
653 method: 'POST',
654 headers: { 'Content-Type': 'application/json', Origin: baseQ },
655 body: JSON.stringify({ title: 'Limit test' }),
656 });
657 assert.equal(convL.status, 201);
658 const convLData = await convL.json();
659
660 // First turn must succeed (limit=1)
661 const turn1 = await authedFetch(
662 baseQ, jarL, csrfL,
663 `/api/conversations/${convLData.id}/turns`,
664 {
665 method: 'POST',
666 headers: { 'Content-Type': 'application/json', Origin: baseQ },
667 body: JSON.stringify({ prompt: 'hello' }),
668 },
669 );
670 assert.equal(turn1.status, 200, 'first turn within limit must succeed');
671 await turn1.text();
672
673 // Create a second conversation for second turn
674 const convL2 = await authedFetch(baseQ, jarL, csrfL, '/api/conversations', {
675 method: 'POST',
676 headers: { 'Content-Type': 'application/json', Origin: baseQ },
677 body: JSON.stringify({ title: 'Limit test 2' }),
678 });
679 assert.equal(convL2.status, 201);
680 const convL2Data = await convL2.json();
681
682 // Second turn must be rejected with 429
683 const turn2 = await authedFetch(
684 baseQ, jarL, csrfL,
685 `/api/conversations/${convL2Data.id}/turns`,
686 {
687 method: 'POST',
688 headers: { 'Content-Type': 'application/json', Origin: baseQ },
689 body: JSON.stringify({ prompt: 'should fail' }),
690 },
691 );
692 assert.equal(turn2.status, 429, 'second turn must be 429 when turns exhausted');
693 const errorData = await turn2.json();
694 assert.ok(errorData.error, '429 must have error field');
695 assert.equal(errorData.error.code, 'guest_quota_turns_exhausted',
696 '429 code must be guest_quota_turns_exhausted');
697 assert.ok(errorData.error.quota, '429 must include quota details');
698 assert.equal(errorData.error.quota.turnsRemaining, 0,
699 'turnsRemaining must be 0 in 429 payload');
700 assert.ok(typeof errorData.error.quota.resetsAt === 'number',
701 '429 quota must include resetsAt');
702
703 // Verify no turn was persisted for the rejected request
704 const convL2Get = await authedFetch(baseQ, jarL, null,
705 `/api/conversations/${convL2Data.id}`);
706 const convL2Detail = await convL2Get.json();
707 assert.equal(convL2Detail.turns.length, 0,
708 'no turn must be persisted when quota is denied');
709
710 } finally {
711 await stopProcess(serverQ);
712 fs.rmSync(tempDirQ, { recursive: true, force: true });
713 }
714 }
715
176 } finally { 716 } finally {
177 await stopProcess(server); 717 await stopProcess(server);
178 fs.rmSync(tempDirectory, { recursive: true, force: true }); 718 fs.rmSync(tempDirectory, { recursive: true, force: true });
179 } 719 }
180 })().catch(error => { 720 })().catch(error => {