Files
xinmi/scripts/verify_session_isolation_browser.cjs
T

240 lines
16 KiB
JavaScript
Raw Normal View History

const assert = require('node:assert/strict')
const fs = require('node:fs')
const crypto = require('node:crypto')
const { execFileSync } = require('node:child_process')
const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance'
const base = 'http://127.0.0.1:18080/api/v1'
const origin = process.env.SESSION_BROWSER_URL || 'http://127.0.0.1:15173'
const prefix = `session-isolation-${Date.now()}`
const checks = [], failures = [], users = [], roles = []
let admin, browser, secondTenant
const literal = value => `'${String(value).replaceAll("'", "''")}'`
const sql = query => execFileSync('docker', ['--context', 'colima-yawan', 'exec', '-i', 'WeKnora-postgres', 'psql', '-X', '-q', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-At', '-v', 'ON_ERROR_STOP=1'], { input: query, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
function check(name, passed) {
checks.push({ name, passed: !!passed })
assert.ok(passed, name)
console.log(`PASS ${name}`)
}
async function api(route, method = 'GET', body, identity = admin) {
const res = await fetch(base + route, { method, headers: { 'Content-Type': 'application/json', ...(identity ? { Authorization: `Bearer ${identity.token}`, 'X-Tenant-ID': '10059' } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) })
assert.ok(res.ok, `${method} ${route}: ${res.status}`)
return res.json()
}
async function main() {
const credentials = JSON.parse(fs.readFileSync(`${directory}/super-admin-login.json`, 'utf8'))
admin = await api('/auth/login', 'POST', { email: credentials.email, password: credentials.password }, null)
const role = (await api('/access/roles', 'POST', { name: prefix, permissions: ['knowledge.read', 'knowledge.write', 'agents.read', 'agents.write', 'chat.read', 'chat.write', 'sharing.read'] })).data
roles.push(role.id)
async function member(label) {
const password = crypto.randomBytes(18).toString('base64url'), email = `${prefix}-${label}@example.test`
const user = (await api('/access/users', 'POST', { username: `${prefix}-${label}`, email, password, role: 'contributor', status: 'active', custom_role_id: role.id })).data
users.push(user.id)
return { email, password, auth: await api('/auth/login', 'POST', { email, password }, null) }
}
const alice = await member('alice'), bob = await member('bob')
secondTenant = Number(sql(`INSERT INTO tenants(name,business,status) VALUES (${literal(prefix)},'session-isolation','active') RETURNING id;`))
sql(`INSERT INTO tenant_members(user_id,tenant_id,role,status) VALUES (${literal(alice.auth.user.id)},${secondTenant},'owner','active');`)
alice.auth = await api('/auth/login', 'POST', { email: alice.email, password: alice.password }, null)
browser = await chromium.launch({ headless: true, channel: 'chrome' })
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
const page = await context.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.goto(`${origin}/login`)
await page.locator('input[type="text"]').first().fill(alice.email)
await page.locator('input[type="password"]').first().fill(alice.password)
await page.locator('button[type="submit"]').first().click()
await page.waitForURL('**/platform/**')
await page.waitForLoadState('networkidle')
check('normal_form_login', await page.evaluate(id => JSON.parse(localStorage.getItem('weknora_user'))?.id === id, alice.auth.user.id))
await page.evaluate(async () => {
const app = document.querySelector('#app').__vue_app__
const pinia = app.config.globalProperties.$pinia
const modules = await Promise.all([
import('/src/stores/auth.ts'), import('/src/stores/access.ts'), import('/src/stores/chatResources.ts'),
import('/src/stores/editorResources.ts'), import('/src/stores/organization.ts'), import('/src/stores/settings.ts'),
import('/src/utils/request.ts'), import('/src/utils/sessionBoundary.ts'), import('/src/utils/citationChunkCache.ts'), import('/src/api/auth/index.ts'),
])
window.isolation = { auth: modules[0].useAuthStore(pinia), access: modules[1].useAccessStore(pinia), chat: modules[2].useChatResourcesStore(pinia), editor: modules[3].useEditorResourcesStore(pinia), org: modules[4].useOrganizationStore(pinia), settings: modules[5].useSettingsStore(pinia), request: modules[6], boundary: modules[7], citations: modules[8] }
window.isolation.login = data => {
const auth = window.isolation.auth
auth.setUser(modules[9].userInfoFromApi(data.user, data.user.tenant_id))
auth.setToken(data.token)
auth.setRefreshToken(data.refresh_token)
auth.setTenant({ ...data.active_tenant, id: String(data.active_tenant.id) })
auth.setSelectedTenant(null, null)
auth.setMemberships(data.memberships)
}
const { createVNode, h, ref, render } = await import('/node_modules/.vite/deps/vue.js')
const { useChatCitationPopover } = await import('/src/composables/useChatCitationPopover.ts')
const host = document.createElement('div')
host.id = 'session-citation-fixture'
document.body.append(host)
const vnode = createVNode({ setup() {
const root = ref(null)
const popover = useChatCitationPopover(root, { sessionId: () => 'isolation-fixture' })
window.isolation.float = popover.float
return () => h('div', { ref: root }, [h('a', { class: 'citation-kb', 'data-chunk-id': 'isolation-fixture-chunk', 'data-doc': 'Disposable citation' }, 'citation')])
} })
vnode.appContext = app._context
render(vnode, host)
})
const paths = ['/api/v1/knowledge-bases', '/api/v1/agents/type-presets', '/api/v1/access/me', '/api/v1/auth/me', '/api/v1/organizations', '/api/v1/chunks/by-id/isolation-fixture-chunk', '/api/v1/sandbox-configs', '/api/v1/skills']
for (const mode of ['account', 'tenant', 'relogin']) {
await page.evaluate(data => window.isolation.login(data), alice.auth)
await page.evaluate(async () => { await window.isolation.access.ensureLoaded(true) })
const held = new Map()
let release
const gate = new Promise(resolve => { release = resolve })
const handler = async route => {
const path = new URL(route.request().url()).pathname
if (!paths.includes(path)) return route.continue()
const synthetic = path.includes('isolation-fixture-chunk') || ['/api/v1/sandbox-configs', '/api/v1/skills'].includes(path)
const response = synthetic ? null : await route.fetch()
if (response) assert.equal(response.status(), 200, `fixture ${path}`)
held.set(path, true)
await gate
if (mode === 'relogin') await route.fulfill({ status: 401, json: { success: false, message: 'Delayed old-session unauthorized response' } })
else if (response) await route.fulfill({ response })
else if (path.includes('isolation-fixture-chunk')) await route.fulfill({ status: 200, json: { data: { content: 'Old private citation body' } } })
else await route.fulfill({ status: 200, json: { data: [{ id: 'old-resource', name: 'Old resource', sandbox_type: 'e2b' }], skills_available: true } })
}
await page.route('**/api/v1/**', handler)
await page.evaluate(() => {
const s = window.isolation
s.chat.invalidate(); s.editor.invalidate(); s.org.clearState()
s.settings.settings.agentConfig.selectedKnowledgeBases = ['old-selection']
s.settings.settings.agentConfig.selectedTags = ['old-tag']
s.settings.settings.conversationModels.chatModelId = 'old-model'
s.oldCitationScope = `${s.boundary.sessionBoundary()}:isolation-fixture`
s.citations.setCitationChunkCache(s.oldCitationScope, 'seeded-chunk', { content: 'Old cached citation' })
localStorage.setItem('WeKnora_settings', JSON.stringify(s.settings.settings))
s.pending = Promise.allSettled([
s.chat.fetchKnowledgeBasesForList({ creator: 'all' }, true), s.editor.ensureAgentTypePresets(true),
s.org.fetchOrganizations({ force: true }), s.access.ensureLoaded(true), s.auth.refreshFromAuthMe(),
s.chat.ensureSandboxConfigs(true), s.editor.ensureSkills('old-sandbox-fixture', true),
])
document.querySelector('#session-citation-fixture .citation-kb').click()
})
const deadline = Date.now() + 15000
while (held.size < paths.length && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25))
assert.equal(held.size, paths.length, `all delayed ${mode} requests started: ${[...held.keys()].join(',')}`)
await page.evaluate(({ mode, auth, tenant }) => {
const s = window.isolation
if (mode === 'tenant') s.auth.setSelectedTenant(tenant, 'Disposable second workspace')
else { s.auth.logout(); s.login(auth) }
s.chat.sandboxConfigs = [{ id: 'new-sandbox', sandbox_type: 'e2b' }]
s.editor.skills = [{ name: 'new-skill', description: 'New session resource' }]
s.editor.skillsAvailable = true
}, { mode, auth: mode === 'account' ? bob.auth : alice.auth, tenant: secondTenant })
release()
await page.evaluate(async () => { await window.isolation.pending })
await page.waitForFunction(() => !window.isolation.float.value.loading)
await page.unroute('**/api/v1/**', handler)
const result = await page.evaluate(() => {
const s = window.isolation
return {
empty: !s.chat.rawKnowledgeBases.length && !s.editor.agentTypePresets.length && !s.org.organizations.length && !s.access.permissions.length && !s.access.loaded,
selections: !JSON.stringify(s.settings.settings).includes('old-selection') && !JSON.stringify(s.settings.settings).includes('old-tag') && !JSON.stringify(s.settings.settings).includes('old-model'),
citation: !s.citations.getCitationChunkCache(s.oldCitationScope, 'seeded-chunk') && !s.citations.getCitationChunkCache(s.oldCitationScope, 'isolation-fixture-chunk') && !s.float.value.content && !s.float.value.visible,
optional: s.chat.sandboxConfigs[0]?.id === 'new-sandbox' && s.editor.skills[0]?.name === 'new-skill' && s.editor.skillsAvailable && !s.chat.isFresh('sandboxConfigs'),
user: s.auth.user.id, tenant: s.auth.selectedTenantId || Number(s.auth.tenant.id), token: !!localStorage.getItem('weknora_token'),
}
})
check(`${mode}_late_responses_do_not_refill_stores`, result.empty)
check(`${mode}_old_selections_are_cleared`, result.selections)
check(`${mode}_late_citation_cannot_refill_cache_or_popup`, result.citation)
check(`${mode}_cancelled_optional_loaders_preserve_current_data`, result.optional)
check(`${mode}_new_identity_survives`, result.user === (mode === 'account' ? bob.auth : alice.auth).user.id && result.tenant === (mode === 'tenant' ? secondTenant : 10059) && result.token && !page.url().includes('/login'))
let tenantHeader
const observe = request => { if (new URL(request.url()).pathname === '/api/v1/auth/me') tenantHeader = request.headers()['x-tenant-id'] }
page.on('request', observe)
const current = await page.evaluate(async () => {
const s = window.isolation
const me = await s.request.get('/api/v1/auth/me')
await s.access.ensureLoaded(true)
await s.chat.fetchKnowledgeBasesForList({ creator: 'all' }, true)
return { user: me.data.user.id, tenant: me.data.tenant.id, loaded: s.access.loaded }
})
page.off('request', observe)
check(`${mode}_new_requests_load_correct_identity`, current.user === result.user && Number(current.tenant) === result.tenant && current.loaded)
if (mode === 'tenant') check('tenant_switch_sends_correct_header', tenantHeader === String(secondTenant))
}
alice.auth = await api('/auth/login', 'POST', { email: alice.email, password: alice.password }, null)
bob.auth = await api('/auth/login', 'POST', { email: bob.email, password: bob.password }, null)
await page.evaluate(data => window.isolation.login(data), alice.auth)
const refreshGates = new Map(), refreshStarted = new Set(), probeAttempts = new Map()
for (const identity of ['alice', 'bob']) {
let release
const promise = new Promise(resolve => { release = resolve })
refreshGates.set(identity, { promise, release })
}
const refreshHandler = async route => {
const url = new URL(route.request().url())
if (url.pathname === '/api/v1/auth/refresh') {
const submitted = route.request().postDataJSON().refreshToken
const identity = submitted === alice.auth.refresh_token ? 'alice' : submitted === bob.auth.refresh_token ? 'bob' : ''
assert.ok(identity, 'refresh belongs to disposable login')
const response = await route.fetch()
assert.equal(response.status(), 200, 'real refresh succeeds')
refreshStarted.add(identity)
await refreshGates.get(identity).promise
return route.fulfill({ response })
}
const probe = url.searchParams.get('session_isolation_probe')
if (url.pathname === '/api/v1/models' && probe) {
const count = (probeAttempts.get(probe) || 0) + 1
probeAttempts.set(probe, count)
if (count === 1) return route.fulfill({ status: 401, json: { success: false, message: 'Controlled expiry for browser refresh verification' } })
}
return route.continue()
}
await page.route('**/api/v1/**', refreshHandler)
async function waitForRefresh(identity) {
const deadline = Date.now() + 15000
while (!refreshStarted.has(identity) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25))
assert.ok(refreshStarted.has(identity), `${identity} refresh started independently`)
}
await page.evaluate(() => {
window.isolation.oldRefresh = window.isolation.request.get('/api/v1/models?session_isolation_probe=alice')
.then(() => ({ cancelled: false }), error => ({ cancelled: error.code === 'ERR_CANCELED' }))
})
await waitForRefresh('alice')
await page.evaluate(data => { window.isolation.auth.logout(); window.isolation.login(data) }, bob.auth)
await page.evaluate(() => {
window.isolation.newRefresh = window.isolation.request.get('/api/v1/models?session_isolation_probe=bob')
.then(response => ({ success: response.success !== false }), error => ({ success: false, code: error.code }))
})
await waitForRefresh('bob')
check('new_login_refresh_starts_while_old_refresh_waits', refreshStarted.size === 2)
refreshGates.get('alice').release()
check('old_refresh_is_cancelled_in_real_browser', (await page.evaluate(() => window.isolation.oldRefresh)).cancelled)
check('old_refresh_preserves_new_login_token', await page.evaluate(token => localStorage.getItem('weknora_token') === token, bob.auth.token))
refreshGates.get('bob').release()
check('new_login_refresh_and_retry_complete', (await page.evaluate(() => window.isolation.newRefresh)).success)
check('new_login_retries_once_after_own_refresh', probeAttempts.get('alice') === 1 && probeAttempts.get('bob') === 2)
await page.unroute('**/api/v1/**', refreshHandler)
check('new_refreshed_token_is_valid_for_current_account', await page.evaluate(async id => {
const response = await window.isolation.request.get('/api/v1/auth/me')
return response.data.user.id === id
}, bob.auth.user.id))
await page.screenshot({ path: `${directory}/session-isolation-browser.png`, animations: 'disabled' })
check('browser_has_no_uncaught_exceptions', errors.length === 0)
}
main().catch(error => { failures.push(error.message); console.error(error.message) }).finally(async () => {
if (browser) await browser.close()
if (secondTenant) {
try { sql(`DELETE FROM tenant_members WHERE tenant_id=${secondTenant}; DELETE FROM tenants WHERE id=${secondTenant};`) } catch { failures.push('cleanup workspace failed') }
}
if (admin) {
for (const id of users) try { await api(`/access/users/${id}`, 'DELETE') } catch { failures.push('cleanup user failed') }
for (const id of roles) try { await api(`/access/roles/${id}`, 'DELETE') } catch { failures.push('cleanup role failed') }
}
const report = { time: new Date().toISOString(), origin, prefix, passed: !failures.length, checks, failures, fixtures_cleaned: !failures.some(f => f.startsWith('cleanup')) }
fs.writeFileSync(`${directory}/session-isolation-browser.json`, JSON.stringify(report, null, 2))
console.log(JSON.stringify({ passed: report.passed, checks: checks.length, failures }))
if (failures.length) process.exitCode = 1
})