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