import assert from 'node:assert/strict' import http from 'node:http' import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' // The real built Next server/UI with isolated API fixtures: no app users or DB writes. const pages = new Map(['a', 'b'].map((id, position) => [id, { id, title: `Page ${id.toUpperCase()}`, content: '', version: 1, position, pinned: false, bookmarks: [] }])) const tags = [{ id: 'one', name: 'First tag', color: '#123456' }, { id: 'two', name: 'Second tag', color: '#654321' }] const kanbanColumns = [ { id: 'inbox', label: 'Inbox', color: '#123456', key: 'INBOX', position: 0 }, { id: 'todo', label: 'To Do', color: '#abcdef', key: 'TODO', position: 1 }, ] const bookmarks = Array.from({ length: 205 }, (_, i) => ({ id: `bookmark-${i}`, ownerId: 'fixture-user', title: `Fixture ${String(i).padStart(3, '0')}`, url: 'https://127.0.0.1/test', description: '', notes: '', tags: [{ tag: tags[0] }], sharedWith: [], kanbanStatus: 'INBOX', kanbanColumnId: 'inbox', kanbanPosition: i, isPublic: false, imapWatchEnabled: false, alertAt: null, dueDate: null, version: 0, createdAt: '2026-10-07T00:00:00Z', updatedAt: '2026-10-07T00:00:00Z', })) let failSaves = false // Review CR-02: forces Go's signout endpoint to fail so the browser test can // prove a revocation failure is reported accurately and local sign-out still // completes. let failSignout = false // Fails only saves whose body.content matches (deterministic mid-loop // failure for the DATA-07 envelope test — no racing the serial queue). let failContent = null // Mirrors the proxy's streaming body cap (web/app/api/[...path]/route.ts). const MAX_PROXY_BODY = 1 << 20 // Records Authorization headers of POST /api/auth/signout so the browser // sign-out test can prove the Next server reached Go's revocation endpoint. const signouts = [] let delaySaves = 0 let activeSaves = 0 let maxConcurrentSaves = 0 let paginatedRequests = 0 // Board moves are recorded so the browser test can prove a drag reached the // API with the card's expected version/position. const kanbanMoves = [] // Counts every PATCH /api/pages/* attempt (including 409/500 rejections) so // tests can prove a paused conflict issues no further saves (review TEST-01). let patchAttempts = 0 const api = http.createServer(async (req, res) => { try { let raw = '' for await (const chunk of req) raw += chunk const body = raw ? JSON.parse(raw) : {} const url = new URL(req.url, 'http://fixture') let result = [] if (url.pathname === '/api/auth/login') result = { user: { id: 'fixture-user', name: 'Fixture', email: body.email, role: 'USER' }, tokenVersion: 1 } else if (url.pathname === '/api/auth/signout' && req.method === 'POST') { signouts.push(req.headers.authorization || '') if (failSignout) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture revocation failed"}'); return } result = { ok: true } } else if (url.pathname === '/api/me') result = { user: { id: 'fixture-user' }, effectiveUser: { id: 'fixture-user' }, isAdmin: false, viewingAs: null } // The list endpoint is a metadata projection: content bodies stay out (PERF-01). else if (url.pathname === '/api/pages') { if (req.method === 'PUT') { for (const [position, id] of (body.order || []).entries()) { const page = pages.get(id) if (page) page.position = position } result = { ok: true } } else { result = [...pages.values()].sort((a, b) => a.position - b.position).map(({ content, ...summary }) => summary) } } else if (url.pathname.startsWith('/api/pages/')) { const id = url.pathname.split('/')[3] if (req.method === 'PATCH') { patchAttempts++ if (failSaves) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture save failed"}'); return } if (failContent !== null && body.content === failContent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"Fixture save failed"}'); return } const target = pages.get(id) // Optimistic concurrency (DATA-01): a stale base version is a 409. if (body.version !== undefined && body.version !== target.version) { res.writeHead(409, { 'Content-Type': 'application/json' }) res.end('{"error":"Page was modified elsewhere; reload to see the latest version"}') return } activeSaves++ maxConcurrentSaves = Math.max(maxConcurrentSaves, activeSaves) await new Promise(resolve => setTimeout(resolve, delaySaves)) const { version, ...data } = body Object.assign(target, data) if ('content' in body) target.version++ activeSaves-- } result = pages.get(id) } else if (url.pathname === '/api/bookmarks') { const shared = url.searchParams.get('shared') === 'true' const matching = shared ? [] : bookmarks.filter(b => b.title.includes(url.searchParams.get('search') || '')) const offset = Number(url.searchParams.get('cursor') || 0) const limit = Number(url.searchParams.get('limit') || 200) // Mirror the Go permission-specific projections (PRIV-02): shared // readers get the narrowed allowlist shape, owners the full row. const project = b => shared ? (({ contact, contactId, sharedWith, alertAt, alertSent, imapQuery, imapWatchEnabled, kanbanColumnId, kanbanPosition, ...rest }) => rest)(b) : b result = matching.slice(offset, offset + limit).map(project) if (offset + limit < matching.length) res.setHeader('X-Next-Cursor', String(offset + limit)) if (offset) paginatedRequests++ } else if (url.pathname === '/api/tags') result = tags else if (url.pathname === '/api/kanban/columns') result = kanbanColumns else if (/^\/api\/kanban\/cards\/[^/]+$/.test(url.pathname) && req.method === 'PUT') { const bookmark = bookmarks.find(b => b.id === url.pathname.split('/')[4]) if (!bookmark) { res.writeHead(404, { 'Content-Type': 'application/json' }) res.end('{"error":"Not found"}') return } if (body.version === undefined) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end('{"error":"version is required when moving a card"}') return } if (body.version !== bookmark.version) { res.writeHead(409, { 'Content-Type': 'application/json' }) res.end('{"error":"Bookmark was modified elsewhere"}') return } const column = kanbanColumns.find(c => c.id === body.columnId) if (!column) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end('{"error":"Invalid columnId"}') return } const target = bookmarks .filter(b => b.id !== bookmark.id && b.kanbanColumnId === column.id) .sort((a, b) => a.kanbanPosition - b.kanbanPosition || b.updatedAt.localeCompare(a.updatedAt)) const position = Math.max(0, Math.min(Number(body.position) || 0, target.length)) target.splice(position, 0, bookmark) bookmark.kanbanColumnId = column.id bookmark.version++ target.forEach((b, i) => { b.kanbanPosition = i }) kanbanMoves.push({ id: bookmark.id, columnId: column.id, position, version: body.version }) result = bookmark } else if (/^\/api\/bookmarks\/[^/]+$/.test(url.pathname)) { const bookmark = bookmarks.find(b => b.id === url.pathname.split('/')[3]) if (req.method === 'PUT') { // Review CR-04: mirror Go's bookmark concurrency contract — version // is required (400), a stale base is 409 with the row untouched, // success bumps the revision. if (body.version === undefined) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end('{"error":"version is required when updating a bookmark; refetch it and retry"}') return } if (body.version !== bookmark.version) { res.writeHead(409, { 'Content-Type': 'application/json' }) res.end('{"error":"Bookmark was modified elsewhere; reload to see the latest version"}') return } const { tagIds, version, ...data } = body Object.assign(bookmark, data) if (tagIds) bookmark.tags = tagIds.map(id => ({ tag: tags.find(tag => tag.id === id) })) bookmark.version++ } result = bookmark } else if (url.pathname.endsWith('/signals')) result = { sources: [], signals: [], types: [] } res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(result)) } catch (error) { res.writeHead(500); res.end(String(error)) } }) await new Promise(resolve => api.listen(0, '127.0.0.1', resolve)) // The free-port probe is TOCTOU-racy: another process can grab the port // between close and `next start`. Retry up to three ports before giving up. let base = '' let web = null let logs = '' for (let attempt = 1; attempt <= 3; attempt++) { const portProbe = http.createServer() await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve)) const port = portProbe.address().port await new Promise(resolve => portProbe.close(resolve)) base = `http://localhost:${port}` logs = '' web = spawn(process.execPath, ['node_modules/next/dist/bin/next', 'start', '--port', String(port)], { cwd: fileURLToPath(new URL('../web/', import.meta.url)), env: { ...process.env, AUTH_SECRET: 'isolated-browser-fixture-secret', NEXTAUTH_SECRET: 'isolated-browser-fixture-secret', NEXTAUTH_URL: base, API_BASE_URL: `http://127.0.0.1:${api.address().port}`, OIDC_ISSUER: '', OIDC_CLIENT_ID: '', OIDC_CLIENT_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'], }) web.stdout.on('data', data => logs += data) web.stderr.on('data', data => logs += data) let started = false for (let i = 0; i < 150; i++) { try { if ((await fetch(`${base}/login`)).ok) { started = true; break } } catch { /* not up yet */ } if (/EADDRINUSE/.test(logs) || web.exitCode !== null) break await new Promise(resolve => setTimeout(resolve, 100)) } if (started) break web.kill('SIGKILL') if (attempt === 3) assert.fail(`Next server did not start after 3 ports: ${logs}`) } const attacker = http.createServer((req, res) => { res.setHeader('Content-Type', 'text/html') res.end(`
`) }) await new Promise(resolve => attacker.listen(0, '127.0.0.1', resolve)) async function eventually(check, label) { for (let i = 0; i < 100; i++) { if (await check()) return await new Promise(resolve => setTimeout(resolve, 100)) } assert.fail(label) } let browser try { await eventually(async () => { try { return (await fetch(`${base}/login`)).ok } catch { return false } }, `Next server did not start: ${logs}`) browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox'] }) const context = await browser.newContext({ timezoneId: 'America/Chicago' }) const page = await context.newPage() const errors = [] page.on('pageerror', error => errors.push(error.message)) page.on('dialog', dialog => dialog.accept()) await page.goto(`http://127.0.0.1:${attacker.address().port}`) await page.waitForURL(`${base}/api/auth/login`) assert.equal((await context.request.get(`${base}/api/auth/session`).then(r => r.json()))?.user, undefined) for (const headers of [{ Origin: 'https://attacker.invalid' }, {}]) { const response = await context.request.post(`${base}/api/auth/login`, { headers, data: { email: 'attacker@computer.test', password: 'password' } }) assert.equal(response.status(), 403) } const plain = await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base, 'Content-Type': 'text/plain' }, data: '{"email":"attacker@computer.test"}' }) assert.equal(plain.status(), 415) const login = await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base }, data: { email: 'fixture@computer.test', password: 'password' } }) assert.equal(login.status(), 200) console.log('PASS: forged logins rejected; legitimate login works') // Review CR-14: an oversized chunked body (no usable Content-Length) must // be rejected by the proxy's streaming limit — counted while read and // cancelled at the cap, never fully buffered first. const chunkedStatus = await new Promise((resolve) => { const payload = Buffer.alloc(MAX_PROXY_BODY + 1, 0x61) const u = new URL(`${base}/api/pages`) const req = http.request({ host: u.hostname, port: u.port, path: u.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' }, }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)) }) req.on('error', () => resolve(-1)) for (let i = 0; i < payload.length; i += 65536) req.write(payload.subarray(i, Math.min(i + 65536, payload.length))) req.end() }) assert.equal(chunkedStatus, 413, 'oversized chunked body was not rejected at the proxy') console.log('PASS: oversized chunked request bodies are rejected while streaming') const editor = page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.') await page.goto(`${base}/pages?id=a`) await editor.fill('A edits before switching') await page.getByRole('button', { name: 'Page B', exact: true }).click() await eventually(() => page.getByRole('textbox', { name: 'Page title' }).inputValue().then(v => v === 'Page B'), 'Page B not selected') await editor.fill('B edits') await eventually(() => pages.get('b').content === 'B edits', 'Page B not saved') assert.equal(pages.get('a').content, 'A edits before switching') console.log('PASS: rapid page switching preserves both pages') delaySaves = 900 maxConcurrentSaves = 0 await editor.fill('First in-flight edit') await eventually(() => activeSaves === 1, 'Save did not start') await editor.fill('Newest edit wins') await eventually(() => pages.get('b').content === 'Newest edit wins' && activeSaves === 0, 'Newer edit did not save') assert.equal(maxConcurrentSaves, 1) delaySaves = 0 console.log('PASS: saves are serialized and newer edits win') failSaves = true await editor.fill('Recover this failed draft') await page.getByRole('button', { name: 'Page A', exact: true }).click() await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor() assert.equal(await page.getByRole('textbox', { name: 'Page title' }).inputValue(), 'Page B') await page.reload() await eventually(() => editor.inputValue().then(value => value === 'Recover this failed draft'), 'Draft not recovered after reload') failSaves = false await page.getByRole('button', { name: 'Save now', exact: true }).click() await eventually(() => pages.get('b').content === 'Recover this failed draft', 'Retry did not persist draft') console.log('PASS: failed saves block switching, survive reload, and can be retried') // Cross-session conflict (review DATA-04/TEST-01): after another "device" // saves a newer version, the 409 pauses autosave in a distinct conflict // state. Typing must not schedule another save; only the explicit // overwrite button may replace the remote content. await page.goto(`${base}/pages?id=a`) await page.getByRole('button', { name: 'Edit', exact: true }).click() await editor.fill('Local draft before conflict') await eventually(() => pages.get('a').content === 'Local draft before conflict', 'Draft did not save before the conflict') pages.get('a').content = 'Remote edit wins' pages.get('a').version++ await editor.fill('Edit after remote change') await page.getByText('This page was changed elsewhere', { exact: true }).waitFor() const attemptsAtConflict = patchAttempts await editor.fill('Typing must not overwrite') await new Promise(resolve => setTimeout(resolve, 1200)) assert.equal(patchAttempts, attemptsAtConflict, 'Typing during a conflict scheduled another save') assert.equal(pages.get('a').content, 'Remote edit wins', 'Conflict silently overwrote the remote content') await page.getByRole('button', { name: 'Overwrite with my draft', exact: true }).click() await eventually(() => pages.get('a').content === 'Typing must not overwrite', 'Explicit overwrite did not persist') console.log('PASS: conflicts pause autosave until an explicit overwrite') // Recovery after reload (review DATA-03/TEST-01): a failed draft remembers // the server version it was based on. When another session saved in the // meantime, recovery enters the conflict state instead of silently // rebasing the stale draft onto the newer version. failSaves = true await page.goto(`${base}/pages?id=b`) await page.getByRole('button', { name: 'Edit', exact: true }).click() await editor.fill('Draft with an older base') await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor() pages.get('b').content = 'Remote saved while draft failed' pages.get('b').version++ failSaves = false await page.reload() await eventually(() => editor.inputValue().then(value => value === 'Draft with an older base'), 'Draft not recovered after reload') await page.getByText('This page was changed elsewhere', { exact: true }).waitFor() // Snapshot AFTER recovery: the reload itself lets the departing page flush // its dirty draft once (the fixture rejects it with a 409 against the newer // version). What must not happen is any save from the recovered session. const attemptsAfterRecovery = patchAttempts await new Promise(resolve => setTimeout(resolve, 1000)) assert.equal(patchAttempts, attemptsAfterRecovery, 'Recovered draft saved without an explicit resolution') assert.equal(pages.get('b').content, 'Remote saved while draft failed', 'Recovery rebased onto and clobbered the newer version') await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click() await eventually(() => editor.inputValue().then(value => value === 'Remote saved while draft failed'), 'Discard did not load the remote content') console.log('PASS: recovered drafts keep their original base and conflict instead of rebasing') // Unknown-provenance draft (review DATA-06): a legacy plain-string draft // carries no baseVersion, so recovery cannot prove it predates the remote // content. It must pause in the review-required conflict state instead of // saving over whatever the server holds. await page.goto(`${base}/pages?id=a`) await eventually(() => page.getByRole('textbox', { name: 'Page title' }).inputValue().then(v => v === 'Page A'), 'Page A did not open') await page.evaluate(() => sessionStorage.setItem('computer:page-draft:a', JSON.stringify('Legacy draft of unknown origin'))) const attemptsBeforeLegacy = patchAttempts await page.reload() await eventually(() => editor.inputValue().then(value => value === 'Legacy draft of unknown origin'), 'Legacy draft not recovered') await page.getByText('This page was changed elsewhere', { exact: true }).waitFor() await new Promise(resolve => setTimeout(resolve, 1000)) assert.equal(patchAttempts, attemptsBeforeLegacy, 'Unknown-base draft saved without review') assert.equal(pages.get('a').content, 'Typing must not overwrite', 'Unknown-base draft clobbered the remote content') await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click() await eventually(() => editor.inputValue().then(value => value === 'Typing must not overwrite'), 'Discard did not load the remote content') console.log('PASS: unknown-base legacy drafts pause for review instead of saving') // Envelope refresh (review DATA-07): when a later save in the same serial // loop fails, the stored recovery envelope must carry the newest content // AND the base advanced by the earlier success — otherwise reload shows a // spurious conflict for a draft that was already cleanly rebased. delaySaves = 900 failContent = 'Envelope part two' await page.goto(`${base}/pages?id=b`) await page.getByRole('button', { name: 'Edit', exact: true }).click() await editor.fill('Envelope part one') await eventually(() => activeSaves === 1, 'First envelope save did not start') await editor.fill('Envelope part two') await page.getByText('Could not save page. Your draft is kept in this tab; retry saving.').first().waitFor() assert.equal(pages.get('b').content, 'Envelope part one', 'First save of the loop did not land') await page.reload() await eventually(() => editor.inputValue().then(value => value === 'Envelope part two'), 'Newest loop content not recovered') assert.equal(await page.getByText('This page was changed elsewhere', { exact: true }).count(), 0, 'Recovery hit a spurious conflict — the stored base was not refreshed after the successful save') delaySaves = 0 failContent = null await page.getByRole('button', { name: 'Save now', exact: true }).click() await eventually(() => pages.get('b').content === 'Envelope part two', 'Retry did not persist the recovered draft') console.log('PASS: recovery envelope keeps the latest content and refreshed base') // Metadata mutations must not rebase a stale draft (review CR-17): a // rename/pin PATCH response carries the CURRENT content revision but never // acknowledges the draft's base. Adopting it would let a stale draft // overwrite newer remote content without any conflict. await page.goto(`${base}/pages?id=a`) await page.getByRole('button', { name: 'Edit', exact: true }).click() await editor.fill('Metadata rebase draft') await eventually(() => pages.get('a').content === 'Metadata rebase draft', 'Draft did not save before the metadata mutation') pages.get('a').content = 'Remote content stays' pages.get('a').version++ await page.locator('button[title="Pin to sidebar"]').click() await eventually(() => pages.get('a').pinned === true, 'Pin metadata patch did not land') await editor.fill('Stale draft after metadata patch') await page.getByText('This page was changed elsewhere', { exact: true }).waitFor() assert.equal(pages.get('a').content, 'Remote content stays', 'Metadata response rebased the stale draft over the remote content') await page.getByRole('button', { name: 'Discard my draft and load latest', exact: true }).click() await eventually(() => editor.inputValue().then(value => value === 'Remote content stays'), 'Discard did not load the remote content') console.log('PASS: metadata mutations do not rebase stale content drafts') await page.goto(`${base}/bookmarks`) await eventually(() => page.locator('tbody tr').count().then(n => n === 205), 'Collection truncated at 200') assert.ok(paginatedRequests > 0, 'Cursor not forwarded through proxy') await page.getByText('Fixture 000', { exact: true }).click() const sheet = page.getByRole('dialog') const fillSheet = async () => { await sheet.getByLabel('URL', { exact: true }).fill('https://127.0.0.1/changed') await sheet.getByLabel('Description', { exact: true }).fill('Edited description') await sheet.getByLabel('First tag', { exact: true }).uncheck() await sheet.getByLabel('Second tag', { exact: true }).check() await sheet.locator('input[type="datetime-local"]').fill('2026-10-08T10:30') } await fillSheet() // Review CR-04: a concurrent edit elsewhere bumps the revision while this // form is open. The save must surface a conflict, preserve the unsaved // draft, and leave the remote state untouched — no silent overwrite. bookmarks[0].version++ bookmarks[0].notes = 'Remote edit wins' await sheet.getByRole('button', { name: 'Save Changes', exact: true }).click() await page.getByText('This bookmark was changed elsewhere', { exact: false }).first().waitFor() assert.equal(bookmarks[0].url, 'https://127.0.0.1/test', 'Stale bookmark save overwrote the remote state') assert.equal(bookmarks[0].notes, 'Remote edit wins', 'Stale bookmark save clobbered the remote notes') // The sheet keeps the unsaved draft and offers an explicit resolution. await sheet.getByRole('button', { name: 'Load latest version', exact: true }).click() await eventually(() => sheet.getByLabel('URL', { exact: true }).inputValue().then(v => v === 'https://127.0.0.1/test'), 'Sheet did not reload the latest version') await fillSheet() await sheet.getByRole('button', { name: 'Save Changes', exact: true }).click() await eventually(() => bookmarks[0].url === 'https://127.0.0.1/changed', 'Bookmark update missing after conflict resolution') assert.equal(bookmarks[0].description, 'Edited description') assert.deepEqual(bookmarks[0].tags.map(({ tag }) => tag.id), ['two']) assert.equal(bookmarks[0].alertAt, '2026-10-08T15:30:00.000Z') assert.equal(bookmarks[0].notes, 'Remote edit wins', 'Conflict resolution lost the remote notes') assert.deepEqual(errors, [], 'Browser runtime errors') console.log('PASS: 205 bookmarks displayed; stale saves conflict visibly; URL/description/tags editable; local alert time converted to UTC') // Kanban drag-and-drop persists within-column ordering and cross-column // moves through the versioned card endpoint. await page.goto(`${base}/kanban`) await page.getByRole('heading', { name: 'Kanban Board' }).waitFor() const inboxColumn = page.locator('[data-column-id="inbox"]') const todoColumn = page.locator('[data-column-id="todo"]') await inboxColumn.locator('[data-bookmark-id="bookmark-2"]').dragTo( inboxColumn.locator('[data-bookmark-id="bookmark-0"]'), { targetPosition: { x: 20, y: 5 } }, ) await eventually(() => kanbanMoves.length === 1, 'Within-column drag did not reach the API') assert.deepEqual(kanbanMoves[0], { id: 'bookmark-2', columnId: 'inbox', position: 0, version: 0 }) await inboxColumn.locator('[data-bookmark-id="bookmark-3"]').dragTo(todoColumn) await eventually(() => kanbanMoves.length === 2, 'Cross-column drag did not reach the API') assert.deepEqual(kanbanMoves[1], { id: 'bookmark-3', columnId: 'todo', position: 0, version: 0 }) await todoColumn.locator('[data-bookmark-id="bookmark-3"]').waitFor() // Touch/keyboard fallback: the card menu moves without a drag. await todoColumn.locator('[data-bookmark-id="bookmark-3"]').getByRole('button', { name: /options/i }).click() await page.getByRole('menuitem', { name: 'Move to Inbox' }).click() await eventually(() => kanbanMoves.length === 3, 'Card menu move did not reach the API') assert.deepEqual(kanbanMoves[2], { id: 'bookmark-3', columnId: 'inbox', position: 204, version: 1 }) await inboxColumn.locator('[data-bookmark-id="bookmark-3"]').waitFor() console.log('PASS: kanban drag reorders within a column and moves cards between columns') // Page tabs share the persisted position with the sidebar; dragging a tab // issues the complete order permutation and both surfaces reload. Reset the // pin from the earlier metadata test so both pages are in one reorder group. pages.get('a').pinned = false await page.goto(`${base}/pages`) await page.locator('[data-page-tab="a"]').waitFor() await page.locator('[data-page-tab="b"] [data-page-tab-drag]').dragTo( page.locator('[data-page-tab="a"]'), { targetPosition: { x: 5, y: 5 } }, ) await eventually(() => pages.get('b')?.position === 0 && pages.get('a')?.position === 1, 'Page tab drag did not reorder') // The sidebar uses the same complete order permutation; drag a back above b. await page.locator('[data-page-id="a"] [data-page-drag-handle]').dragTo( page.locator('[data-page-id="b"]'), { targetPosition: { x: 10, y: 5 } }, ) await eventually(() => pages.get('a')?.position === 0 && pages.get('b')?.position === 1, 'Sidebar page drag did not reorder') console.log('PASS: page tabs and sidebar persist drag reordering') // Revocation failure path (review CR-02): when Go's revocation fails, the // local sign-out still completes, but the UI must say so — local cookie // removal is not confirmed global revocation, and other sessions survive. await page.goto(`${base}/bookmarks`) await page.keyboard.press('Escape') // close any detail sheet so the top bar is reachable failSignout = true const failedRevoke = page.waitForResponse(r => r.url().includes('/api/session/revoke') && r.request().method() === 'POST') await page.getByRole('button', { name: 'Sign out', exact: true }).click() await page.getByText('global revocation failed', { exact: false }).first().waitFor() assert.equal((await (await failedRevoke).json()).revoked, false, 'Revoke route reported success despite the backend failure') await page.waitForURL(`${base}/login`) failSignout = false console.log('PASS: failed global revocation is reported and local sign-out still completes') // Browser sign-out must reach Go's revocation (review AUTH-LIFECYCLE): the // explicit /api/session/revoke call (with the NextAuth signOut event as // backstop) posts to Go /api/auth/signout with a signed assertion, so the // tokenVersion bump kills Go cookie sessions and any previously minted // assertions — not just the local NextAuth JWT. await context.request.post(`${base}/api/auth/login`, { headers: { Origin: base }, data: { email: 'fixture@computer.test', password: 'password' } }) await page.goto(`${base}/pages`) const revoked = page.waitForResponse(r => r.url().includes('/api/session/revoke') && r.request().method() === 'POST') await page.getByRole('button', { name: 'Sign out', exact: true }).click() assert.equal((await (await revoked).json()).revoked, true, 'Revoke route did not confirm global revocation') await page.waitForURL(`${base}/login`) await eventually(() => signouts.length > 0, 'Go signout was not called during browser sign-out') const authHeader = signouts[signouts.length - 1] assert.ok(authHeader.startsWith('Bearer '), 'Signout call carried no bearer assertion') const claim = JSON.parse(Buffer.from(authHeader.slice('Bearer '.length).split('.')[1], 'base64url').toString()) assert.equal(claim.uid, 'fixture-user') assert.equal(claim.typ, 'assertion') assert.equal(claim.ver, 1) assert.deepEqual(errors, [], 'Browser runtime errors during sign-out') console.log('PASS: browser sign-out revokes sessions through the Go backend') } finally { await browser?.close() web.kill('SIGTERM') api.closeAllConnections(); api.close() attacker.closeAllConnections(); attacker.close() }