Files

170 lines
17 KiB
JavaScript
Raw Permalink Normal View History

const assert = require('node:assert/strict')
const fs = require('node:fs')
const crypto = require('node:crypto')
const { execFileSync } = require('node:child_process')
const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance'
const base = process.env.ISOLATION_API_URL || 'http://127.0.0.1:18080/api/v1'
const reportName = process.env.ISOLATION_REPORT || 'knowledge-versions-live.json'
assert.match(reportName, /^[a-zA-Z0-9_-]+\.json$/)
const prefix = `knowledge-version-check-${Date.now()}`
const checks = [], failures = [], kbs = new Set(), users = [], roles = []
let admin
const withGraph=process.argv.includes('--with-graph')
const graphSources=kb=>rows(`SELECT * FROM native_graph_sources WHERE tenant_id=10059 AND knowledge_base_id=${literal(kb)} ORDER BY source_chunk_id`)
const graphRecords=kb=>rows(`SELECT source_table,legacy_id,payload,source_snapshot_sha256 FROM native_graph_records WHERE tenant_id=10059 AND knowledge_base_id=${literal(kb)} ORDER BY source_table,legacy_id`)
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 rows = statement => JSON.parse(sql(`SELECT COALESCE(json_agg(t),'[]') FROM (${statement}) t`))
const check = (name, value) => { checks.push({name,passed:!!value}); assert.ok(value,name); console.log(name) }
async function api(route,method='GET',body,identity=admin,expected=200) {
const form = body instanceof FormData
const response = await fetch(base+route,{method,headers:{...(form?{}:{'Content-Type':'application/json'}),...(identity?{Authorization:`Bearer ${identity.token}`,'X-Tenant-ID':'10059'}:{})},body:body===undefined?undefined:form?body:JSON.stringify(body),signal:AbortSignal.timeout(45000)})
const raw = await response.text()
let result; try {result=JSON.parse(raw)} catch {result={message:raw.slice(0,200)}}
assert.ok((Array.isArray(expected)?expected:[expected]).includes(response.status),`${method} ${route}: ${response.status} ${result.message||result.error?.message||''}`)
return result
}
async function until(name, fn, timeout=180000) {
const start=Date.now()
while(Date.now()-start<timeout) { const result=await fn(); if(result)return result; await new Promise(r=>setTimeout(r,1000)) }
throw new Error(`${name} timed out after ${timeout/1000}s`)
}
async function versionJob(forkID,purpose,identity) {
return until(`${purpose} job`,async()=>{
const data=(await api('/resource-versions','GET',undefined,identity)).data
const job=data.jobs.find(j=>j.fork_id===forkID&&j.purpose===purpose)
if(job?.status==='failed') {
const diagnostics=rows(`SELECT details FROM audit_logs WHERE action='resource.knowledge_copy_failed' AND target_id=${literal(job.id)} ORDER BY created_at DESC LIMIT 1`)
throw new Error(`Version copy failed: ${JSON.stringify(diagnostics)}`)
}
return job?.status==='completed'?data:null
})
}
function documents(kb) {return rows(`SELECT id,file_path,parse_status,error_message FROM knowledges WHERE tenant_id=10059 AND knowledge_base_id=${literal(kb)} AND deleted_at IS NULL ORDER BY id`)}
async function chunk(doc,identity) {return (await api(`/chunks/${doc}?page_size=100`,'GET',undefined,identity)).data[0]}
async function vectorRows(kb) {
const result=await fetch('http://127.0.0.1:19530/v2/vectordb/entities/query',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({collectionName:'yawan_acceptance_hybrid_512',filter:`knowledge_base_id == "${kb}"`,outputFields:['id','knowledge_id','chunk_id','knowledge_base_id'],limit:100})}).then(r=>r.json())
assert.equal(result.code,0,JSON.stringify(result))
return result.data
}
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']})).data;roles.push(role.id)
async function member(label) {
const password=crypto.randomBytes(18).toString('base64url'),email=`${prefix}-${label}@example.test`
const u=(await api('/access/users','POST',{username:prefix+'-'+label,email,password,role:'contributor',status:'active',custom_role_id:role.id})).data;users.push(u.id)
return api('/auth/login','POST',{email,password},null)
}
const alice=await member('alice'),bob=await member('bob')
const source=(await api('/knowledge-bases','POST',{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}},admin,[200,201])).data;kbs.add(source.id)
const content='Native knowledge version verification. The archive code is cobalt 731. This small document checks independent file and vector copies.'
const form=new FormData();form.set('file',new Blob([content],{type:'text/plain'}),'version-fixture.txt')
await api(`/knowledge-bases/${source.id}/knowledge/file`,'POST',form,admin,[200,201,202])
const sourceDoc=await until('native ingestion',async()=>{
const doc=documents(source.id)[0]
if(doc?.parse_status==='failed')throw new Error(`Ingestion failed: ${doc.error_message}`)
return doc?.parse_status==='completed'?doc:null
})
const original=await chunk(sourceDoc.id,admin)
check('native_upload_indexed',!!original?.id)
check('real_milvus_source_index', (await vectorRows(source.id)).some(v=>v.chunk_id===original.id))
if(withGraph) {
const digest=crypto.createHash('sha256').update(original.content).digest('hex')
sql(`INSERT INTO native_graph_records(tenant_id,knowledge_base_id,source_table,legacy_id,graph_version_id,payload,source_snapshot_sha256) VALUES (10059,${literal(source.id)},'knowledgeentity',7,1,'{"id":7,"canonical_name":"Archive code"}', 'fixture-provenance'),(10059,${literal(source.id)},'knowledgegraphevidence',8,1,'{"id":8,"subject_type":"entity","subject_id":7,"source_chunk_id":"exact"}', 'fixture-provenance'); INSERT INTO native_graph_sources(tenant_id,knowledge_base_id,source_chunk_id,source_file,source_content_hash,excerpt,native_knowledge_id,native_chunk_id,native_content_sha256,mapping_state) VALUES (10059,${literal(source.id)},'exact','original.jsonl',${literal(digest)},'cobalt 731',${literal(sourceDoc.id)},${literal(original.id)},${literal(digest)},'exact_content_hash'),(10059,${literal(source.id)},'unmapped','missing.jsonl','historical-hash','preserved unmatched excerpt',NULL,NULL,NULL,'unmapped')`)
}
await api('/access/grants','POST',{resources:[{kind:'knowledge',id:source.id}],user_ids:[],action:'publish'})
const fork=(await api(`/resource-versions/knowledge/${source.id}/fork`,'POST',{},alice)).data;kbs.add(fork.personal_id)
await versionJob(fork.id,'fork',alice)
const personalDoc=documents(fork.personal_id)[0],personal=await chunk(personalDoc.id,alice)
check('independent_document_file_and_chunk',personalDoc.id!==sourceDoc.id&&personalDoc.file_path!==sourceDoc.file_path&&personal.id!==original.id)
check('real_milvus_personal_index',(await vectorRows(fork.personal_id)).some(v=>v.chunk_id===personal.id&&v.knowledge_id===personalDoc.id))
if(withGraph) {
const sources=graphSources(fork.personal_id)
check('personal_graph_preserves_records_and_provenance',JSON.stringify(graphRecords(fork.personal_id))===JSON.stringify(graphRecords(source.id))&&sources.length===2&&sources[1].excerpt==='preserved unmatched excerpt')
check('personal_graph_remaps_exact_chunk_and_hash',sources[0].native_chunk_id===personal.id&&sources[0].native_knowledge_id===personalDoc.id&&sources[0].native_content_sha256===crypto.createHash('sha256').update(personal.content).digest('hex')&&sources[1].native_chunk_id===null)
}
await api(`/knowledge-bases/${fork.personal_id}`,'GET',undefined,bob,[403,404])
await api(`/knowledge/${personalDoc.id}/download`,'GET',undefined,alice,403)
await api('/knowledge-bases/copy','POST',{source_id:fork.personal_id},alice,403)
check('normal_user_isolation_and_no_export',true)
const edited='Submitted revision: the archive code is emerald 928. This exact immutable version must be published.'
await api(`/chunks/${personalDoc.id}/${personal.id}`,'PUT',{content:edited,is_enabled:true,expected_revision:personal.content_revision||0},alice)
check('personal_edit_does_not_change_source',(await chunk(sourceDoc.id,admin)).content===original.content)
await until('edit indexing',async()=>{const c=await chunk(personalDoc.id,alice);return c.index_status==='ready'||c.index_status==='completed'})
const submission=(await api(`/resource-versions/forks/${fork.id}/submit`,'POST',{note:'Disposable real storage acceptance'},alice)).data;kbs.add(submission.snapshot_id)
await versionJob(fork.id,'submission',alice)
const snapshotDoc=documents(submission.snapshot_id)[0]
check('immutable_stage_has_independent_raw_file',snapshotDoc.file_path!==personalDoc.file_path&&snapshotDoc.file_path!==sourceDoc.file_path)
await api(`/knowledge-bases/${submission.snapshot_id}`,'GET',undefined,alice,[403,404])
await api(`/knowledge-bases/${submission.snapshot_id}`,'GET',undefined,admin,[403,404])
const beforeSecond=await chunk(personalDoc.id,alice)
await api(`/chunks/${personalDoc.id}/${personal.id}`,'PUT',{content:'Later personal revision, not the approved snapshot.',is_enabled:true,expected_revision:beforeSecond.content_revision},alice)
const review=(await api(`/resource-versions/submissions/${submission.id}/knowledge?document_id=${snapshotDoc.id}`,'GET',undefined,alice)).data
check('fulltext_review_retains_submitted_content',review.chunks[0].content===edited)
await api(`/resource-versions/submissions/${submission.id}/knowledge`,'GET',undefined,bob,404)
await api(`/resource-versions/submissions/${submission.id}/knowledge?document_id=${sourceDoc.id}`,'GET',undefined,alice,404)
check('snapshot_fulltext_user_and_document_isolation',true)
if(withGraph) {
const sources=graphSources(submission.snapshot_id)
check('snapshot_preserves_graph_but_rejects_edited_old_evidence',JSON.stringify(graphRecords(submission.snapshot_id))===JSON.stringify(graphRecords(source.id))&&sources[0].native_chunk_id===null&&sources[0].mapping_state==='stale'&&sources[1].excerpt==='preserved unmatched excerpt')
sql(`UPDATE native_graph_records SET payload=jsonb_set(payload,'{canonical_name}','"Concurrent graph edit"') WHERE tenant_id=10059 AND knowledge_base_id=${literal(source.id)} AND source_table='knowledgeentity'`)
await api(`/resource-versions/submissions/${submission.id}/review`,'POST',{approve:true,note:'Must conflict on graph change'},admin,409)
sql(`UPDATE native_graph_records SET payload=jsonb_set(payload,'{canonical_name}','"Archive code"') WHERE tenant_id=10059 AND knowledge_base_id=${literal(source.id)} AND source_table='knowledgeentity'`)
check('approval_rejects_changed_global_graph',true)
}
sql(`UPDATE knowledge_bases SET name=${literal(prefix+' concurrent update')} WHERE tenant_id=10059 AND id=${literal(source.id)}`)
await api(`/resource-versions/submissions/${submission.id}/review`,'POST',{approve:true,note:'Must conflict on source change'},admin,409)
sql(`UPDATE knowledge_bases SET name=${literal(prefix)} WHERE tenant_id=10059 AND id=${literal(source.id)}`)
check('approval_rejects_changed_global_knowledge',true)
await api(`/resource-versions/submissions/${submission.id}/review`,'POST',{approve:true,note:'Fixture approved'},bob,403)
await api(`/resource-versions/submissions/${submission.id}/review`,'POST',{approve:true,note:'Fixture approved'},admin)
const approved=(await api('/resource-versions','GET',undefined,alice)).data.submissions.find(s=>s.id===submission.id)
check('approval_promotes_distinct_physical_snapshot',approved.published_id===submission.snapshot_id&&approved.published_id!==source.id)
check('approval_retains_submitted_text',(await chunk(snapshotDoc.id,bob)).content===edited)
if(withGraph)check('published_graph_retains_immutable_evidence',graphRecords(approved.published_id).length===2&&graphSources(approved.published_id).length===2)
await api(`/resource-versions/submissions/${submission.id}/knowledge`,'GET',undefined,alice,409)
check('real_milvus_published_index',(await vectorRows(approved.published_id)).length>0)
const search=(await api(`/knowledge-bases/${approved.published_id}/hybrid-search`,'POST',{query_text:'emerald 928 archive code',match_count:5,vector_threshold:0.01,disable_keywords_match:true},bob)).data
check('published_native_vector_search_returns_snapshot',search.some(r=>r.content?.includes('emerald 928')))
const same=(await api(`/resource-versions/knowledge/${source.id}/fork`,'POST',{},alice)).data
check('old_global_id_resolves_to_existing_personal_version',same.id===fork.id)
await api(`/knowledge-bases/${source.id}`,'DELETE',undefined,admin,[200,202])
await until('source deleted',()=>documents(source.id).length===0)
const download=await fetch(`${base}/knowledge/${snapshotDoc.id}/download`,{headers:{Authorization:`Bearer ${admin.token}`,'X-Tenant-ID':'10059'}})
assert.equal(download.status,200)
check('published_file_survives_source_deletion',(await download.text())===content)
check('published_index_survives_source_deletion',(await vectorRows(approved.published_id)).length>0)
if(process.argv.includes('--with-gc')) {
// Convert only this test's unused private copy into an abandoned stage.
// Its real files and Milvus rows exercise the native durable cleanup path.
sql(`UPDATE knowledge_bases SET version_state='copying',is_temporary=true WHERE tenant_id=10059 AND id=${literal(fork.personal_id)}; UPDATE workbench_resource_forks SET status='failed' WHERE tenant_id=10059 AND id=${literal(fork.id)}; UPDATE workbench_knowledge_version_jobs SET status='failed',updated_at=now()-interval '4 hours' WHERE tenant_id=10059 AND fork_id=${literal(fork.id)} AND purpose='fork'`)
console.log('waiting_for_native_staging_cleanup')
await until('native staging garbage collection',()=>rows(`SELECT id FROM knowledge_bases WHERE tenant_id=10059 AND id=${literal(fork.personal_id)}`).length===0,100000)
check('staging_gc_removes_only_disposable_failed_copy',documents(fork.personal_id).length===0&&documents(approved.published_id).length===1)
check('staging_gc_removes_milvus_copy_only',(await vectorRows(fork.personal_id)).length===0&&(await vectorRows(approved.published_id)).length>0)
if(withGraph)check('staging_gc_removes_graph_copy_only',graphRecords(fork.personal_id).length===0&&graphSources(fork.personal_id).length===0&&graphRecords(approved.published_id).length===2)
}
}
main().catch(e=>{failures.push(e.message);process.exitCode=1}).finally(async()=>{
if(admin) {
const related=rows(`SELECT target_id,abandoned_ids FROM workbench_knowledge_version_jobs WHERE tenant_id=10059 AND owner_id IN (${users.length?users.map(literal).join(','):"''"})`)
for(const job of related){kbs.add(job.target_id);for(const id of job.abandoned_ids||[])kbs.add(id)}
for(const id of [...kbs].reverse())try {
// These generated test IDs never refer to pre-existing application data.
sql(`UPDATE knowledge_bases SET version_state='',is_temporary=false WHERE tenant_id=10059 AND id=${literal(id)}`)
await api(`/knowledge-bases/${id}`,'DELETE',undefined,admin,[200,202,404])
}catch(e){failures.push('cleanup KB: '+e.message)}
const ids=[...kbs].map(literal).join(',')||"''", owners=users.map(literal).join(',')||"''"
if(withGraph)sql(`DELETE FROM native_graph_sources WHERE tenant_id=10059 AND knowledge_base_id IN (${ids}); DELETE FROM native_graph_records WHERE tenant_id=10059 AND knowledge_base_id IN (${ids})`)
sql(`DELETE FROM workbench_knowledge_version_jobs WHERE tenant_id=10059 AND owner_id IN (${owners}); DELETE FROM workbench_resource_submissions WHERE tenant_id=10059 AND owner_id IN (${owners}); DELETE FROM workbench_resource_forks WHERE tenant_id=10059 AND owner_id IN (${owners}); DELETE FROM workbench_knowledge_version_aliases WHERE tenant_id=10059 AND (source_id IN (${ids}) OR current_id IN (${ids})); DELETE FROM workbench_access_publications WHERE tenant_id=10059 AND resource_id IN (${ids}); DELETE FROM workbench_resource_hidden WHERE tenant_id=10059 AND owner_id IN (${owners})`)
for(const id of users)try{await api(`/access/users/${id}`,'DELETE')}catch(e){failures.push('cleanup user: '+e.message)}
for(const id of roles)try{await api(`/access/roles/${id}`,'DELETE')}catch(e){failures.push('cleanup role: '+e.message)}
}
fs.writeFileSync(`${directory}/${reportName}`,JSON.stringify({time:new Date().toISOString(),base,prefix,fixtures:{knowledge_bases:[...kbs],users,roles},checks,failures,passed:failures.length===0},null,2))
console.log(JSON.stringify({checks,failures,passed:failures.length===0}))
if(failures.length)process.exitCode=1
})