Files

171 lines
13 KiB
JavaScript
Raw Permalink Normal View History

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 }