view mrjunejune/test/conversation_api_test.js @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -0700
parents 056790c4fb0d
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;
});