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 });