Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
240 lines
16 KiB
JavaScript
240 lines
16 KiB
JavaScript
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
|
|
})
|