view mrjunejune/test/conversation_api_test.js @ 271:13d61401c57d

redirect Copilot cache to service state Set XDG_CACHE_HOME from the configured inference state so the systemd service can extract Copilot outside its protected home directory. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 13:24:05 -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;
});