const startedAt = new Date().toISOString() const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const crypto = require('node:crypto') const { execFileSync } = require('node:child_process') const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance' const origin = process.env.FILE_ISOLATION_ORIGIN || 'http://127.0.0.1:18080' const prefix = `file-isolation-${Date.now()}` const tenantID = 10059 const checks = [], failures = [], cleanup = [] const fixtures = { users: [], roles: [], kbs: [], docs: [], sessions: [], messages: [], resources: [], message_scopes: [] } let admin 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 cleanupMessageFixture(fixture, runPrefix, executeSQL = sql) { const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i assert.match(runPrefix, /^file-isolation-\d+$/) assert.equal(fixture.tenant_id, tenantID) assert.match(fixture.session_id, uuid) assert.match(fixture.user_id, uuid) assert.equal(fixture.title, runPrefix) assert.equal(fixture.username, `${runPrefix}-alice`) assert.equal(fixture.email, `${fixture.username}@example.test`) assert.equal(fixture.messages.length, 2) const expectedContents = ['Disposable attachment fixture.', 'Unrelated message.'] fixture.messages.forEach((message, index) => { assert.match(message.id, uuid) assert.equal(message.content, expectedContents[index]) }) assert.equal(new Set(fixture.messages.map(message => message.id)).size, 2) const sessionScope = `id=${literal(fixture.session_id)} AND tenant_id=${tenantID} AND user_id=${literal(fixture.user_id)} AND title=${literal(fixture.title)}` const messageScope = message => `id=${literal(message.id)} AND session_id=${literal(fixture.session_id)} AND role='assistant' AND content=${literal(message.content)}` // Guard the exact disposable owner, session and rows before deleting test messages. executeSQL(`BEGIN; DO $message_cleanup$ BEGIN IF NOT EXISTS (SELECT 1 FROM users WHERE id=${literal(fixture.user_id)} AND tenant_id=${tenantID} AND username=${literal(fixture.username)} AND email=${literal(fixture.email)}) THEN RAISE EXCEPTION 'Message cleanup account does not match this verification run'; END IF; IF NOT EXISTS (SELECT 1 FROM sessions WHERE ${sessionScope}) THEN RAISE EXCEPTION 'Message cleanup session does not match this verification run'; END IF; ${fixture.messages.map(message => ` IF EXISTS (SELECT 1 FROM messages WHERE id=${literal(message.id)}) AND NOT EXISTS (SELECT 1 FROM messages WHERE ${messageScope(message)}) THEN RAISE EXCEPTION 'Message cleanup row does not match this verification run'; END IF;`).join('\n')} END; $message_cleanup$; ${fixture.messages.map(message => `DELETE FROM messages WHERE ${messageScope(message)} AND EXISTS (SELECT 1 FROM sessions WHERE ${sessionScope});`).join('\n')} COMMIT;`) const remaining = executeSQL(`SELECT count(*) FROM messages WHERE id IN (${fixture.messages.map(message => literal(message.id)).join(',')})`) assert.equal(remaining, '0', `Message cleanup left rows for fixture session ${fixture.session_id}`) } function check(name, condition, detail) { checks.push({ name, passed: !!condition, ...(detail ? { detail } : {}) }) assert.ok(condition, `${name}${detail ? ': ' + detail : ''}`) } async function raw(route, identity = admin, method = 'GET', body) { const response = await fetch(origin + route, { method, headers: { 'Content-Type': 'application/json', ...(identity ? { Authorization: `Bearer ${identity.token}`, 'X-Tenant-ID': String(tenantID) } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) }) const text = await response.text() return { status: response.status, text, cache: response.headers.get('cache-control') } } async function api(route, method = 'GET', body, identity = admin) { const result = await raw('/api/v1' + route, identity, method, body) assert.ok(result.status >= 200 && result.status < 300, `${method} ${route}: HTTP ${result.status}`) return result.text ? JSON.parse(result.text) : {} } async function fileCheck(name, route, identity, allowed, marker) { const response = await raw(route, identity) check(name, allowed ? response.status === 200 && response.text === marker : [401, 403, 404].includes(response.status), `HTTP ${response.status}`) if (allowed) check(name + '_not_publicly_cached', /private/.test(response.cache) && /no-store/.test(response.cache)) } async function main() { const credentials = JSON.parse(fs.readFileSync(path.join(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', 'chat.read', 'chat.write'] })).data fixtures.roles.push(role.id) cleanup.push(() => api(`/access/roles/${role.id}`, 'DELETE')) 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 fixtures.users.push(user.id) cleanup.push(() => api(`/access/users/${user.id}`, 'DELETE')) return api('/auth/login', 'POST', { email, password }, null) } const alice = await member('alice'), bob = await member('bob') const makeKB = async (label, identity) => { const kb = (await api('/knowledge-bases', 'POST', { name: prefix + '-' + label, type: 'document', indexing_strategy: { vector_enabled: false, keyword_enabled: false, wiki_enabled: false, graph_enabled: false } }, identity)).data fixtures.kbs.push(kb.id) cleanup.push(async () => { await api(`/knowledge-bases/${kb.id}`, 'DELETE') sql(`DELETE FROM workbench_access_publications WHERE tenant_id=${tenantID} AND resource_id=${literal(kb.id)}`) }) return kb } const aliceKB = await makeKB('alice', alice), bobKB = await makeKB('bob', bob) const doc = (await api(`/knowledge-bases/${aliceKB.id}/knowledge/manual`, 'POST', { title: prefix, content: 'Disposable file access fixture.', status: 'draft' }, alice)).data fixtures.docs.push(doc.id) const fixtureDirectory = path.join(directory, 'files', String(tenantID), 'exports', prefix) fs.mkdirSync(fixtureDirectory, { recursive: true }) cleanup.push(() => fs.rmSync(fixtureDirectory, { recursive: true, force: true })) const makeResource = (label, ownerType, ownerID, relation = 'attachment') => { const id = crypto.randomUUID(), handle = crypto.randomBytes(16).toString('base64url') const physical = `local://${tenantID}/exports/${prefix}/${label}.txt`, marker = `${prefix} ${label}` fs.writeFileSync(path.join(fixtureDirectory, label + '.txt'), marker) const locationHash = crypto.createHash('sha256').update(physical).digest('hex') fixtures.resources.push(id) cleanup.push(() => sql(`DELETE FROM resource_bindings WHERE resource_id=${literal(id)}; DELETE FROM resources WHERE id=${literal(id)}`)) sql(`INSERT INTO resources(id,handle,tenant_id,provider,physical_path,location_hash,mime_type,original_name,size,created_at,updated_at) VALUES (${literal(id)},${literal(handle)},${tenantID},'local',${literal(physical)},${literal(locationHash)},'text/plain',${literal(label + '.txt')},${Buffer.byteLength(marker)},now(),now()); INSERT INTO resource_bindings(id,resource_id,tenant_id,owner_type,owner_id,relation,created_at) VALUES (${literal(crypto.randomUUID())},${literal(id)},${tenantID},${literal(ownerType)},${literal(ownerID)},${literal(relation)},now())`) return { ref: 'resource://' + handle, physical, marker } } const file = makeResource('knowledge', 'knowledge', doc.id) const generic = '/files?file_path=' + encodeURIComponent(file.ref) await fileCheck('owner_reads_private_knowledge_file', generic, alice, true, file.marker) await fileCheck('anonymous_denied', generic, null, false) await fileCheck('other_user_handle_denied', generic, bob, false) await fileCheck('other_user_physical_path_denied', '/files?file_path=' + encodeURIComponent(file.physical), bob, false) await fileCheck('owner_scoped_knowledge_file', `/api/v1/knowledge-bases/${aliceKB.id}/files?file_path=${encodeURIComponent(file.ref)}`, alice, true, file.marker) await fileCheck('wrong_knowledge_base_cannot_proxy_private_file', `/api/v1/knowledge-bases/${bobKB.id}/files?file_path=${encodeURIComponent(file.ref)}`, bob, false) await api('/access/grants', 'POST', { resources: [{ kind: 'knowledge', id: aliceKB.id }], user_ids: [bob.user.id], action: 'grant' }) await fileCheck('explicit_grant_reads_file', generic, bob, true, file.marker) const source = makeResource('original', 'knowledge', doc.id, 'source_file') await fileCheck('owner_can_read_original', '/files?file_path=' + encodeURIComponent(source.ref), alice, true, source.marker) await fileCheck('admin_can_read_original', '/files?file_path=' + encodeURIComponent(source.ref), admin, true, source.marker) await fileCheck('grantee_cannot_download_original', '/files?file_path=' + encodeURIComponent(source.ref), bob, false) await fileCheck('grantee_cannot_download_original_physical_path', '/files?file_path=' + encodeURIComponent(source.physical), bob, false) await fileCheck('grantee_cannot_proxy_original_through_kb', `/api/v1/knowledge-bases/${aliceKB.id}/files?file_path=${encodeURIComponent(source.ref)}`, bob, false) await api('/access/grants', 'POST', { resources: [{ kind: 'knowledge', id: aliceKB.id }], user_ids: [bob.user.id], action: 'revoke' }) await fileCheck('revocation_blocks_file_immediately', generic, bob, false) const session = (await api('/sessions', 'POST', { title: prefix }, alice)).data fixtures.sessions.push(session.id) cleanup.push(() => api(`/sessions/${session.id}`, 'DELETE', undefined, alice)) check('other_user_cannot_read_session', [403,404].includes((await raw(`/api/v1/sessions/${session.id}`, bob)).status)) sql(`UPDATE sessions SET user_id=NULL WHERE id=${literal(session.id)}`) try { check('ownerless_session_not_shared', [403,404].includes((await raw(`/api/v1/sessions/${session.id}`, bob)).status)) check('ownerless_session_not_listed', !(await raw('/api/v1/sessions', bob)).text.includes(session.id)) check('ownerless_session_cannot_be_deleted', [403,404].includes((await raw(`/api/v1/sessions/${session.id}`, bob, 'DELETE')).status)) } finally { sql(`UPDATE sessions SET user_id=${literal(alice.user.id)} WHERE id=${literal(session.id)}`) } const messageID = crypto.randomUUID(), otherMessageID = crypto.randomUUID() const messageFixture = { tenant_id: tenantID, session_id: session.id, user_id: alice.user.id, username: `${prefix}-alice`, email: `${prefix}-alice@example.test`, title: prefix, messages: [{ id: messageID, content: 'Disposable attachment fixture.' }, { id: otherMessageID, content: 'Unrelated message.' }], } fixtures.messages.push(messageID, otherMessageID) fixtures.message_scopes.push(messageFixture) cleanup.push(() => { cleanupMessageFixture(messageFixture, prefix) check(`message_cleanup_${session.id}`, true) }) sql(`INSERT INTO messages(id,request_id,session_id,role,content,is_completed,created_at,updated_at) VALUES (${literal(messageID)},${literal(crypto.randomUUID())},${literal(session.id)},'assistant','Disposable attachment fixture.',true,now(),now()),(${literal(otherMessageID)},${literal(crypto.randomUUID())},${literal(session.id)},'assistant','Unrelated message.',true,now(),now())`) const chatFile = makeResource('message', 'message', messageID) await fileCheck('owner_reads_message_file', '/files?file_path=' + encodeURIComponent(chatFile.ref), alice, true, chatFile.marker) await fileCheck('other_user_message_file_denied', '/files?file_path=' + encodeURIComponent(chatFile.ref), bob, false) await fileCheck('exact_message_file_scope', `/api/v1/sessions/${session.id}/messages/${messageID}/files?file_path=${encodeURIComponent(chatFile.ref)}`, alice, true, chatFile.marker) await fileCheck('different_message_cannot_proxy_file', `/api/v1/sessions/${session.id}/messages/${otherMessageID}/files?file_path=${encodeURIComponent(chatFile.ref)}`, alice, false) check('no_ai_usage', sql(`SELECT count(*) FROM workbench_usage WHERE owner_id IN (${literal(alice.user.id)},${literal(bob.user.id)})`) === '0') } if (require.main === module) main().catch(error => { failures.push(error.message); process.exitCode = 1 }).finally(async () => { for (const action of cleanup.reverse()) { try { await action() } catch (error) { failures.push('cleanup: ' + error.message); process.exitCode = 1 } } const report = { started_at: startedAt, time: new Date().toISOString(), prefix, origin, checks, failures, fixtures, passed: failures.length === 0 } fs.writeFileSync(path.join(directory, process.env.FILE_ISOLATION_REPORT || 'resource-files-live.json'), JSON.stringify(report, null, 2)) console.log(JSON.stringify(report, null, 2)) }) module.exports = { cleanupMessageFixture }