WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user