initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+500
View File
@@ -0,0 +1,500 @@
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(`<form method="POST" action="${base}/api/auth/login" enctype="text/plain"><input name='{"email":"attacker@computer.test","password":"password","extra":"' value='"}'></form><script>document.forms[0].submit()</script>`)
})
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()
}