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 })