Files

92 lines
8.4 KiB
JavaScript
Raw Permalink Normal View History

// Disposable fixtures in the isolated native acceptance database only.
const fs = require('node:fs')
const path = require('node:path')
const crypto = require('node:crypto')
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance'
const base = 'http://127.0.0.1:18080/api/v1', tenant = '10059'
const prefix = `resumable-recovery-${Date.now()}`, checks = [], fixtures = { uploads: [], kbs: [], users: [], roles: [], documents: [] }
let admin
const hash = (bytes, algorithm = 'sha256') => crypto.createHash(algorithm).update(bytes).digest('hex')
const literal = value => `'${String(value).replaceAll("'", "''")}'`
const sql = statement => execFileSync('docker', ['--context', 'colima-yawan', 'exec', 'WeKnora-postgres', 'psql', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-v', 'ON_ERROR_STOP=1', '-Atc', statement], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
const check = (name, value) => { assert.ok(value, name); checks.push({ name, passed: true }); console.log(name) }
const uuid5 = name => {
const bytes = crypto.createHash('sha1').update(Buffer.from('6ba7b8129dad11d180b400c04fd430c8', 'hex')).update(name).digest().subarray(0, 16)
bytes[6] = (bytes[6] & 15) | 0x50; bytes[8] = (bytes[8] & 63) | 0x80
return bytes.toString('hex').replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5')
}
async function api(route, method = 'GET', body, identity = admin, expected = 200, headers = {}) {
const binary = Buffer.isBuffer(body)
const response = await fetch(base + route, { method, headers: { 'Content-Type': binary ? 'application/octet-stream' : 'application/json', ...(identity ? { Authorization: `Bearer ${identity.token}`, 'X-Tenant-ID': tenant } : {}), ...headers }, body: body === undefined ? undefined : binary ? body : JSON.stringify(body), signal: AbortSignal.timeout(120000) })
const raw = await response.text(); let result
try { result = JSON.parse(raw) } catch { result = { message: raw.slice(0, 300) } }
assert.ok((Array.isArray(expected) ? expected : [expected]).includes(response.status), `${method} ${route}: HTTP ${response.status}; ${result.message || result.error?.message || ''}`)
return result
}
async function wait(id) {
for (let deadline = Date.now() + 180000; Date.now() < deadline;) {
const row = (await api(`/uploads/${id}`)).data
if (['completed', 'failed'].includes(row.state)) return row
await new Promise(resolve => setTimeout(resolve, 1000))
}
throw new Error('Native recovery timed out')
}
async function createUpload(kb, name, bytes, identity = admin) {
const row = (await api('/uploads', 'POST', { kind: 'knowledge', target_id: kb.id, name, size: bytes.length, sha256: hash(bytes) }, identity, 201)).data
fixtures.uploads.push({ id: row.id, identity })
await api(`/uploads/${row.id}/chunks`, 'PUT', bytes, identity, 200, { 'Upload-Offset': '0', 'Upload-Checksum': hash(bytes) })
return row
}
async function main() {
const credentials = JSON.parse(fs.readFileSync(`${directory}/super-admin-login.json`, 'utf8'))
admin = await api('/auth/login', 'POST', credentials, null)
const kbForm = { name: prefix, type: 'document', embedding_model_id: 'a2c3ce32-6192-46c8-8072-910abfe8afed', storage_backend_id: 'edcd8dc2-4e93-4bd4-839c-d835619fc0a6', indexing_strategy: { vector_enabled: true, keyword_enabled: true, wiki_enabled: false, graph_enabled: false } }
const kb = (await api('/knowledge-bases', 'POST', kbForm, admin, [200, 201])).data; fixtures.kbs.push(kb.id)
for (const status of ['pending', 'failed']) {
const bytes = Buffer.from(`Native crash recovery evidence ${status}: persimmon 492. This stored source must be indexed exactly once.`)
const upload = await createUpload(kb, `recovery-${status}.txt`, bytes)
const digestRows = (await api(`/uploads/${upload.id}/chunks?offset=0`)).data
check(`${status}_prefix_digest_matches`, digestRows.length === 1 && digestRows[0].sha256 === hash(bytes))
const id = uuid5(`resumable:${tenant}:${kb.id}:${upload.id}:0`); fixtures.documents.push(id)
// Reproduce the persisted native row and file just before queue delivery.
const relative = `${tenant}/${id}/recovery.txt`
fs.mkdirSync(path.dirname(`${directory}/files/${relative}`), { recursive: true })
fs.writeFileSync(`${directory}/files/${relative}`, bytes)
sql(`INSERT INTO knowledges (id,tenant_id,knowledge_base_id,type,channel,source,title,file_name,file_type,file_size,file_hash,file_path,parse_status,enable_status,embedding_model_id,metadata,created_at,updated_at) VALUES (${literal(id)},10059,${literal(kb.id)},'file','web','',${literal(`recovery-${status}.txt`)},${literal(`recovery-${status}.txt`)},'txt',${bytes.length},${literal(hash(bytes, 'md5'))},${literal(`local://${relative}`)},${literal(status)},'disabled',${literal(kbForm.embedding_model_id)},${literal(JSON.stringify({ resumable_upload_id: upload.id, resumable_upload_offset: '0' }))}::jsonb,now(),now())`)
await api(`/uploads/${upload.id}/finalize`, 'POST', {}, admin, 202)
const complete = await wait(upload.id)
check(`${status}_native_recovery_completes`, complete.state === 'completed')
check(`${status}_reuses_deterministic_document`, complete.result.knowledge_ids.length === 1 && complete.result.knowledge_ids[0] === id)
const chunks = (await api(`/chunks/${id}?page_size=20`)).data
check(`${status}_native_chunk_evidence`, chunks.some(row => row.content.includes('persimmon 492')))
check(`${status}_no_duplicate_native_document`, sql(`SELECT count(*) FROM knowledges WHERE metadata->>'resumable_upload_id'=${literal(upload.id)} AND deleted_at IS NULL`) === '1')
}
const role = (await api('/access/roles', 'POST', { name: prefix, permissions: ['knowledge.read', 'knowledge.write'] })).data; fixtures.roles.push(role.id)
const password = crypto.randomBytes(18).toString('base64url'), email = `${prefix}@example.test`
const user = (await api('/access/users', 'POST', { username: prefix, email, password, role: 'contributor', status: 'active', custom_role_id: role.id })).data; fixtures.users.push(user.id)
const member = await api('/auth/login', 'POST', { email, password }, null)
const memberKB = (await api('/knowledge-bases', 'POST', { ...kbForm, name: prefix + '-member' }, member, [200, 201])).data; fixtures.kbs.push(memberKB.id)
const upload = await createUpload(memberKB, 'revoke.txt', Buffer.from('revocation material'), member)
await api(`/access/roles/${role.id}`, 'PUT', { ...role, permissions: ['knowledge.read'] })
await api(`/uploads/${upload.id}`, 'GET', undefined, member, 403)
await api(`/uploads/${upload.id}/chunks?offset=0`, 'GET', undefined, member, 403)
await api(`/uploads/${upload.id}/chunks`, 'PUT', Buffer.from('revocation material'), member, 403, { 'Upload-Offset': '0', 'Upload-Checksum': hash(Buffer.from('revocation material')) })
await api(`/uploads/${upload.id}/finalize`, 'POST', {}, member, 403)
check('revocation_hides_list_and_blocks_read_write_finalize', !(await api('/uploads', 'GET', undefined, member)).data.some(row => row.id === upload.id))
await api(`/uploads/${upload.id}`, 'DELETE', undefined, member)
check('revoked_owner_can_release_staging', sql(`SELECT count(*) FROM resumable_upload_chunks WHERE upload_id=${literal(upload.id)}`) === '0' && !fs.existsSync(`${directory}/resumable-uploads/${upload.id}.upload`))
}
async function cleanup() {
for (const row of fixtures.uploads) try { await api(`/uploads/${row.id}`, 'DELETE', undefined, row.identity, [200, 404, 409]) } catch {}
for (const id of fixtures.kbs) try { await api(`/knowledge-bases/${id}`, 'DELETE', undefined, admin, [200, 202, 404]) } catch {}
for (const id of fixtures.users) try { await api(`/access/users/${id}`, 'DELETE', undefined, admin, [200, 404]) } catch {}
for (const id of fixtures.roles) try { await api(`/access/roles/${id}`, 'DELETE', undefined, admin, [200, 404]) } catch {}
for (const id of fixtures.documents) fs.rmSync(`${directory}/files/${tenant}/${id}`, { recursive: true, force: true })
}
main().catch(error => { checks.push({ name: 'failure', message: error.message }); console.error(error.message); process.exitCode = 1 }).finally(async () => {
await cleanup()
fs.writeFileSync(`${directory}/resumable-recovery-verification.json`, JSON.stringify({ prefix, checks, fixtures: { ...fixtures, uploads: fixtures.uploads.map(row => row.id) }, finished_at: new Date().toISOString() }, null, 2))
})