Files
xinmi/scripts/verify_access_control.cjs
T

280 lines
25 KiB
JavaScript
Raw Normal View History

const startedAt = new Date().toISOString()
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.VERIFY_API_ORIGIN || 'http://127.0.0.1:18080') + '/api/v1'
const origin = process.env.VERIFY_FRONTEND_ORIGIN || 'http://127.0.0.1:15173'
const reportName = process.env.VERIFY_REPORT_NAME || 'access-control-live'
assert.match(reportName, /^[a-zA-Z0-9_-]+$/)
const prefix = `access-check-${Date.now()}`
const checks = [], cleanup = [], failures = []
let admin, member, browser
const check = (name, value) => { checks.push({name, passed:!!value}); assert.ok(value, name) }
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 literal = value => `'${String(value).replaceAll("'", "''")}'`
function validateResponse(route, method, result, status, expected, onUnexpectedSuccess) {
const accepted=(Array.isArray(expected)?expected:[expected]).includes(status)
if(!accepted&&status>=200&&status<300&&onUnexpectedSuccess)onUnexpectedSuccess(result)
assert.ok(accepted, `${method} ${route}: HTTP ${status}; ${result.message || result.error?.message || ''}`)
return result
}
function trackCreatedResource(collection, excludedIDs=[], actions=cleanup, remove=route=>api(route,'DELETE',undefined,admin,[200,202,204,404]), executeSQL=sql) {
return result=>{
const data=result.data
const id=collection==='/knowledge-bases' ? data?.target_id || data?.knowledge_base?.id
: collection==='/workbench/resources/flows' ? data?.personal_id : data?.id
assert.ok(typeof id==='string'&&id&&!excludedIDs.includes(id),'Unexpected creation must return a new resource ID')
actions.push(()=>remove(`${collection}/${encodeURIComponent(id)}?detach=true`))
if(collection==='/workbench/resources/flows'){
assert.ok(typeof data.id==='string'&&data.id&&data.id!==id,'Unexpected fork must return its separate metadata ID')
actions.push(()=>executeSQL(`BEGIN;
DELETE FROM workbench_resource_forks WHERE tenant_id=10059 AND kind='flows' AND id=${literal(data.id)} AND personal_id=${literal(id)};
DELETE FROM workbench_access_publications WHERE tenant_id=10059 AND kind='flows' AND resource_id=${literal(id)};
COMMIT;`))
}
}
}
async function finishVerification({browser,cleanup,failures,writeReport}) {
if(browser)try{await browser.close()}catch(error){failures.push('browser close: '+error.message)}
for(const action of [...cleanup].reverse())try{await action()}catch(error){failures.push('cleanup: '+error.message)}
await writeReport()
}
async function api(route, method='GET', body, identity=admin, expected=200, onUnexpectedSuccess) {
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(30000)})
const result = response.status===204 ? {} : await response.json()
return validateResponse(route,method,result,response.status,expected,onUnexpectedSuccess)
}
async function browserContext(identity, viewport={width:1440,height:1000}) {
const context = await browser.newContext({viewport})
await context.addInitScript(a=>{
const values={weknora_user:JSON.stringify({...a.user,tenant_id:String(a.user.tenant_id)}),weknora_tenant:JSON.stringify({...a.active_tenant,id:'10059'}),weknora_token:a.token,weknora_refresh_token:a.refresh_token,weknora_memberships:JSON.stringify(a.memberships)}
for(const [key,value] of Object.entries(values))localStorage.setItem(key,value)
localStorage.setItem('weknora:new-user-guide-done:v1','1')
},identity)
return context
}
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,description:'Disposable permission verification',permissions:['content.read']})).data
cleanup.push(()=>api(`/access/roles/${role.id}`,'DELETE'))
check('new_role_uses_explicit_permission_version',role.permission_version===2)
await api('/access/roles','POST',{name:prefix+'-invalid',permissions:['content.write']},admin,400,trackCreatedResource('/access/roles',[role.id]))
await api(`/access/roles/${role.id}`,'PUT',{...role,version:0},admin,409)
check('role_validation_and_optimistic_version',true)
const password=crypto.randomBytes(18).toString('base64url')
const userForm={username:prefix,email:`${prefix}@example.test`,password,role:'contributor',status:'active',custom_role_id:role.id}
const user=(await api('/access/users','POST',userForm)).data
let userDeleted=false
cleanup.push(async()=>{if(!userDeleted)await api(`/access/users/${user.id}`,'DELETE')})
member=await api('/auth/login','POST',{email:userForm.email,password},null)
const initialAccess=(await api('/access/me','GET',undefined,member)).data
check('created_user_can_login',initialAccess.restricted)
check('new_role_has_exact_read_only_permissions',JSON.stringify(initialAccess.permissions)===JSON.stringify(['content.read']))
await api('/access/users','GET',undefined,member,403)
await api('/access/resources','GET',undefined,member,403)
await api('/agents','GET',undefined,member,403)
await api('/agents/draft','POST',{model_id:'unavailable',description:'This request must not invoke a model.'},member,403)
await api('/knowledge-bases','GET',undefined,member,403)
await api('/shared-agents','GET',undefined,member,403)
await api('/shared-knowledge-bases','GET',undefined,member,403)
await api('/workbench/resources/content-tasks','POST',{name:prefix+'-blocked',data:{}},member,403,trackCreatedResource('/workbench/resources/content-tasks'))
await api('/workbench/resources/content-tasks','GET',undefined,member)
await api(`/access/users/${admin.user.id}`,'DELETE',undefined,admin,400)
await api(`/access/roles/${role.id}`,'DELETE',undefined,admin,409)
check('feature_and_admin_routes_enforced',true)
const {chromium}=require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
browser=await chromium.launch({headless:true,channel:'chrome'})
const restrictedContext=await browserContext(member), restrictedPage=await restrictedContext.newPage()
const pageFailures=[]
restrictedPage.on('pageerror',e=>pageFailures.push(e.message))
restrictedPage.on('response',r=>{if(r.status()>=400 && new URL(r.url()).pathname.startsWith('/api/'))pageFailures.push(`${r.status()} ${new URL(r.url()).pathname}`)})
await restrictedPage.goto(origin+'/platform/workbench/content?tab=tasks')
await restrictedPage.waitForLoadState('networkidle')
check('restricted_menu_hides_agents_and_access',await restrictedPage.locator('[data-guide="nav-agents"], [data-guide="nav-workbench/access"]').count()===0)
check('read_only_content_has_no_create_button',await restrictedPage.getByRole('button',{name:'创建内容任务',exact:true}).count()===0)
await restrictedPage.locator('[data-guide="user-menu"]').click()
const memberItems = await restrictedPage.locator('.user-dropdown .menu-item').allTextContents()
check('ordinary_user_menu_has_only_manual_and_logout',memberItems.length===2 && memberItems.some(text=>text.trim()==='操作手册') && !memberItems.some(text=>/设置|管理/.test(text)))
await restrictedPage.locator('.dropdown-user-header').click()
check('ordinary_user_identity_does_not_mount_settings',await restrictedPage.locator('.settings-overlay').count()===0)
await restrictedPage.screenshot({path:`${directory}/${reportName}-member-menu.png`,animations:'disabled'})
for(const section of ['general','userprofile','tenant','members','models','skills','runtime-queues']) {
await restrictedPage.goto(origin+'/platform/settings?section='+section)
await restrictedPage.waitForURL('**/platform/help')
check('ordinary_user_settings_route_blocked:'+section,await restrictedPage.locator('.settings-overlay,.settings-modal').count()===0)
}
await restrictedPage.goto(origin+'/platform/workbench/access')
await restrictedPage.waitForURL('**/platform/help')
check('direct_page_access_redirects',true)
check('real_member_help_hides_admin_chapters',await restrictedPage.locator('.help-section[id^="admin-"]').count()===0)
check('restricted_page_no_failed_background_requests: '+pageFailures.join(', '),pageFailures.length===0)
await restrictedContext.close()
const resourcePermissions=['content.read','content.write','flows.read','flows.write','flows.delete','agents.read','agents.write','agents.skills','knowledge.read','knowledge.write','digital-humans.read']
const updatedRole=(await api(`/access/roles/${role.id}`,'PUT',{...role,permissions:resourcePermissions})).data
const updatedAccess=(await api('/access/me','GET',undefined,member)).data
check('updated_role_keeps_explicit_permission_version',updatedRole.permission_version===2)
check('updated_role_does_not_expand_write_permissions',JSON.stringify([...updatedAccess.permissions].sort())===JSON.stringify([...resourcePermissions].sort()))
const moduleContext=await browserContext(member),modulePage=await moduleContext.newPage(),moduleFailures=[]
modulePage.on('pageerror',e=>moduleFailures.push(e.message))
modulePage.on('response',r=>{if(r.status()>=400 && new URL(r.url()).pathname.startsWith('/api/'))moduleFailures.push(`${r.status()} ${new URL(r.url()).pathname}`)})
for(const route of ['workbench/flows','digital-humans','agents','knowledge-bases']){
await modulePage.goto(origin+'/platform/'+route)
await modulePage.waitForLoadState('networkidle')
}
check('permitted_modules_without_chat_or_sharing_load: '+moduleFailures.join(', '),moduleFailures.length===0)
await moduleContext.close()
const agents=(await api('/agents')).data
const source=agents.find(a=>a.config?.model_id)
assert.ok(source,'Configured model fixture')
const agent=(await api('/agents','POST',{name:prefix+'-agent',config:{...source.config,knowledge_bases:[],knowledge_base_ids:[],kb_selection_mode:'none',skills:[],instruction_skill_ids:[],fusion_agent_ids:[],digital_human_id:''}},admin,201)).data
cleanup.push(()=>api(`/agents/${agent.id}?detach=true`,'DELETE'))
const flow=(await api('/workbench/resources/flows','POST',{name:prefix+'-flow',data:{nodes:[{id:'a',label:'A',agent_id:agent.id}],edges:[]}},admin,201)).data
cleanup.push(()=>api(`/workbench/resources/flows/${flow.id}?detach=true`,'DELETE'))
const form=new FormData();form.set('name',prefix+'-skill');form.set('file',new Blob(['# Review\nCheck structure and citations.'],{type:'text/markdown'}),'review.md')
const skill=(await api('/skills/instructions/import','POST',form,admin,201)).data
cleanup.push(()=>api(`/skills/instructions/${skill.id}?detach=true`,'DELETE'))
const human=(await api('/digital-humans','POST',{name:prefix+'-human',model_id:source.config.model_id},admin,201)).data
cleanup.push(()=>api(`/digital-humans/${human.id}?detach=true`,'DELETE'))
const kb=(await api('/knowledge-bases','POST',{name:prefix+'-knowledge',type:'document',embedding_model_id:'a2c3ce32-6192-46c8-8072-910abfe8afed',summary_model_id:source.config.model_id,indexing_strategy:{vector_enabled:true,keyword_enabled:true,wiki_enabled:false,graph_enabled:false}},admin,[200,201])).data
cleanup.push(()=>api(`/knowledge-bases/${kb.id}`,'DELETE',undefined,admin,[200,202]))
await api(`/agents/${agent.id}`,'PUT',{name:agent.name,config:{...agent.config,kb_selection_mode:'selected',knowledge_bases:[kb.id],instruction_skill_ids:[skill.id]}})
// A metadata-only fixture verifies document authorization without embedding or model calls.
const knowledgeID=crypto.randomUUID()
sql(`INSERT INTO knowledges (id,tenant_id,knowledge_base_id,type,title,source,parse_status,enable_status,created_at,updated_at) VALUES (${literal(knowledgeID)},10059,${literal(kb.id)},'manual',${literal(prefix+'-document')},'manual','completed','enabled',now(),now())`)
cleanup.push(()=>sql(`DELETE FROM knowledges WHERE id=${literal(knowledgeID)}`))
const fixtures=[{kind:'agents',id:agent.id,route:`/agents/${agent.id}`},{kind:'flows',id:flow.id,route:`/workbench/resources/flows/${flow.id}`},{kind:'skills',id:skill.id,route:`/skills/instructions/${skill.id}`},{kind:'digital-humans',id:human.id,route:`/digital-humans/${human.id}`},{kind:'knowledge',id:kb.id,route:`/knowledge-bases/${kb.id}`}]
const resources=fixtures.map(({kind,id})=>({kind,id}))
cleanup.push(async()=>{for(const table of ['workbench_access_grants','workbench_access_publications'])sql(`DELETE FROM ${table} WHERE tenant_id=10059 AND resource_id IN (${fixtures.map(f=>literal(f.id)).join(',')})`)})
async function grants(action){await api('/access/grants','POST',{resources,user_ids:['grant','revoke'].includes(action)?[user.id]:[],action})}
await grants('unpublish')
for(const fixture of fixtures)await api(fixture.route,'GET',undefined,member,[403,404])
await grants('grant')
for(const fixture of fixtures)await api(fixture.route,'GET',undefined,member)
await api(`/agents/${human.primary_agent_id}`,'GET',undefined,member)
check('five_resource_types_and_derived_agent_grant',true)
check('granted_document_batch_visible',(await api(`/knowledge/batch?ids=${knowledgeID}`,'GET',undefined,member)).data.some(k=>k.id===knowledgeID))
await api(`/knowledge/${knowledgeID}/download`,'GET',undefined,member,403)
await api(`/knowledge-bases/${kb.id}/faq/entries/export`,'GET',undefined,member,403)
check('grants_do_not_allow_document_download_or_export',true)
await api(`/agents/${agent.id}/copy`,'POST',{},member,403,trackCreatedResource('/agents',[agent.id]))
await api(`/knowledge-bases/${kb.id}/duplicate`,'POST',{},member,403,trackCreatedResource('/knowledge-bases',[kb.id]))
check('grants_do_not_allow_resource_duplication',true)
await api(`/workbench/resources/flows/${flow.id}`,'PUT',{name:'forbidden',version:flow.version,data:flow.data},member,[403,404])
await api(`/agents/${agent.id}`,'PUT',{name:'forbidden',config:agent.config},member,403)
await api(`/knowledge-bases/${kb.id}/knowledge/manual`,'POST',{title:prefix+'-forbidden',content:'must not be created'},member,403,trackCreatedResource('/knowledge'))
check('grants_do_not_transfer_ownership',true)
await grants('revoke')
for(const fixture of fixtures)await api(fixture.route,'GET',undefined,member,[403,404])
await api(`/agents/${human.primary_agent_id}`,'GET',undefined,member,[403,404])
check('revoked_document_batch_hidden',!(await api(`/knowledge/batch?ids=${knowledgeID}`,'GET',undefined,member)).data.some(k=>k.id===knowledgeID))
await grants('publish')
for(const fixture of fixtures)await api(fixture.route,'GET',undefined,member)
await api(`/agents/${human.primary_agent_id}`,'GET',undefined,member)
check('published_document_batch_visible',(await api(`/knowledge/batch?ids=${knowledgeID}`,'GET',undefined,member)).data.some(k=>k.id===knowledgeID))
await api(`/knowledge/${knowledgeID}/download`,'GET',undefined,member,403)
await api(`/knowledge-bases/${kb.id}/faq/entries/export`,'GET',undefined,member,403)
check('global_publication_does_not_allow_document_export',true)
await api(`/agents/${agent.id}/copy`,'POST',{},member,403,trackCreatedResource('/agents',[agent.id]))
await api(`/knowledge-bases/${kb.id}/duplicate`,'POST',{},member,403,trackCreatedResource('/knowledge-bases',[kb.id]))
await api(`/workbench/resources/flows/${flow.id}`,'DELETE',undefined,member,[403,404])
await api(`/skills/instructions/${skill.id}?detach=true`,'DELETE',undefined,member,[403,404])
await api(`/agents/${agent.id}`,'PUT',{name:'forbidden',config:agent.config},member,403)
await api(`/resource-versions/flows/${flow.id}/fork`,'POST',{},member,410,trackCreatedResource('/workbench/resources/flows',[flow.id]))
check('global_resources_deny_copy_mutation_and_retired_forks',true)
const personalAgent=(await api('/agents','POST',{name:prefix+'-member-agent',config:{...source.config,knowledge_bases:[],knowledge_base_ids:[],kb_selection_mode:'none',skills:[],instruction_skill_ids:[],fusion_agent_ids:[],digital_human_id:''}},member,201)).data
cleanup.push(()=>api(`/agents/${personalAgent.id}?detach=true`,'DELETE'))
await api('/skills/instructions/mount','POST',{agent_ids:[personalAgent.id],skill_ids:[skill.id],mode:'add'},member)
check('published_skill_mounts_on_members_own_agent',(await api(`/agents/${personalAgent.id}`,'GET',undefined,member)).data.config.instruction_skill_ids.includes(skill.id))
const personalFlow=(await api('/workbench/resources/flows','POST',{name:prefix+'-member-flow',data:{nodes:[{id:'shared',label:'Shared Agent',agent_id:agent.id}],edges:[]}},member,201)).data
cleanup.push(()=>api(`/workbench/resources/flows/${personalFlow.id}?detach=true`,'DELETE'))
check('published_agent_usable_in_members_own_canvas',personalFlow.owner_id===member.user.id)
const editedFlow=(await api(`/workbench/resources/flows/${personalFlow.id}`,'PUT',{name:prefix+'-admin-edited',version:personalFlow.version,data:personalFlow.data})).data
check('administrator_can_edit_members_canvas',editedFlow.name===prefix+'-admin-edited'&&editedFlow.owner_id===member.user.id)
const editedAgent=(await api(`/agents/${personalAgent.id}`,'PUT',{name:prefix+'-admin-agent',config:personalAgent.config})).data
check('administrator_can_edit_members_agent',editedAgent.name===prefix+'-admin-agent'&&editedAgent.created_by===member.user.id)
await api('/access/grants','POST',{resources:[{kind:'agents',id:agent.id},{kind:'knowledge',id:kb.id},{kind:'skills',id:skill.id}],user_ids:[],action:'unpublish'})
for(const fixture of fixtures.filter(item=>['agents','knowledge','skills'].includes(item.kind)))await api(fixture.route,'GET',undefined,member,[403,404])
const sharedFlowTask=(await api('/workbench/resources/content-tasks','POST',{name:prefix+'-shared-flow-task',data:{flow_id:flow.id,source_text:'第一天:识别主题;第二天:练习应用。'}},member,201)).data
cleanup.push(()=>api(`/workbench/resources/content-tasks/${sharedFlowTask.id}?detach=true`,'DELETE'))
check('member_can_create_task_using_canvas_private_dependencies',sharedFlowTask.owner_id===member.user.id&&sharedFlowTask.data.flow_id===flow.id&&sharedFlowTask.data.knowledge_base_ids.includes(kb.id))
await grants('unpublish')
for(const fixture of fixtures)await api(fixture.route,'GET',undefined,member,[403,404])
await api(`/agents/${human.primary_agent_id}`,'GET',undefined,member,[403,404])
check('unpublished_document_batch_hidden',!(await api(`/knowledge/batch?ids=${knowledgeID}`,'GET',undefined,member)).data.some(k=>k.id===knowledgeID))
check('revoke_publish_unpublish_effective',true)
const adminContext=await browserContext(admin),page=await adminContext.newPage(),adminErrors=[]
page.on('pageerror',e=>adminErrors.push(e.name))
page.on('response',r=>{if(r.status()>=400&&new URL(r.url()).pathname.startsWith('/api/'))adminErrors.push(`${r.status()} ${new URL(r.url()).pathname}`)})
await page.goto(origin+'/platform/workbench/access')
await page.getByRole('heading',{name:'访问控制',exact:true}).waitFor()
await page.getByText('角色权限',{exact:true}).click()
await page.getByRole('button',{name:'创建角色',exact:true}).click()
const catalog=(await api('/access/catalog')).data
const roleDrawer=page.locator('.t-drawer--open').filter({hasText:'创建角色'})
await roleDrawer.waitFor({state:'visible'})
// Groups mount asynchronously; wait for the rendered count instead of racing it.
await page.waitForFunction(expected=>document.querySelectorAll('.t-drawer--open .permission-group-heading').length>=expected,catalog.length,{timeout:20000}).catch(()=>{})
check('role_drawer_lists_every_sidebar_module',await roleDrawer.locator('.permission-group-heading').count()===catalog.length && catalog.length>=13)
const adminGroups=catalog.filter(group=>group.admin_only)
check('catalog_contains_administrator_only_groups',adminGroups.length>0)
for(const group of adminGroups){
const heading=page.getByText(group.name,{exact:true})
const panel=roleDrawer.locator('.t-collapse-panel').filter({has:heading})
const body=panel.locator('.t-collapse-panel__body')
if(!await body.isVisible())await panel.locator('.t-collapse-panel__header').click()
await body.locator('.admin-permission-note').waitFor({state:'visible'})
check('administrator_group_has_no_grant_checkboxes:'+group.id,await body.locator('input[type="checkbox"],[role="checkbox"],.t-checkbox').count()===0)
for(const permission of group.permissions)await body.locator('.permission-options').getByText(permission.name,{exact:true}).waitFor({state:'visible'})
}
await roleDrawer.locator('.admin-permission-note').first().scrollIntoViewIfNeeded()
await page.screenshot({path:`${directory}/${reportName}-roles-desktop.png`,animations:'disabled'})
if(process.env.VERIFY_PC_ONLY!=='1'){
await page.setViewportSize({width:390,height:844})
await roleDrawer.locator('.admin-permission-note').first().scrollIntoViewIfNeeded()
await page.screenshot({path:`${directory}/${reportName}-roles-mobile.png`,animations:'disabled'})
check('role_drawer_mobile_no_overflow',await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2))
}
await roleDrawer.getByRole('button',{name:'取消',exact:true}).click()
await page.setViewportSize({width:1440,height:1000})
await page.getByText('资源授权',{exact:true}).click()
await page.getByText(prefix+'-agent',{exact:true}).waitFor()
await page.screenshot({path:`${directory}/${reportName}-desktop.png`,animations:'disabled'})
if(process.env.VERIFY_PC_ONLY!=='1'){
await page.setViewportSize({width:390,height:844})
await page.screenshot({path:`${directory}/${reportName}-mobile.png`,animations:'disabled'})
check('access_mobile_no_horizontal_overflow',await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2))
}
check('admin_access_page_no_errors',adminErrors.length===0)
await adminContext.close()
await api(`/access/users/${user.id}`,'PUT',{...userForm,password:'',role:'viewer',custom_role_id:''})
const viewerAccess=(await api('/access/me','GET',undefined,member)).data
check('native_viewer_does_not_advertise_creation',!viewerAccess.permissions.includes('content.write')&&!viewerAccess.permissions.includes('digital-humans.write')&&!viewerAccess.permissions.includes('flows.write'))
await api('/workbench/resources/content-tasks','POST',{name:prefix+'-viewer-blocked',data:{}},member,403,trackCreatedResource('/workbench/resources/content-tasks'))
await api(`/access/users/${user.id}`,'PUT',{...userForm,password:'',status:'suspended'})
await api('/access/me','GET',undefined,member,[401,403])
check('suspend_revokes_existing_session',true)
// Simulate a migrated inactive account using only the disposable fixture.
sql(`UPDATE users SET is_active=false WHERE id=${literal(user.id)}`)
await api(`/access/users/${user.id}`,'PUT',{...userForm,password:'',status:'active'},admin,400)
const nextPassword=crypto.randomBytes(18).toString('base64url'),nextEmail=`updated-${prefix}@example.test`
await api(`/access/users/${user.id}`,'PUT',{...userForm,email:nextEmail,password:nextPassword})
member=await api('/auth/login','POST',{email:nextEmail,password:nextPassword},null)
check('migrated_account_activation_requires_reset_and_allows_email_edit',true)
check('deletion_fixture_has_membership_and_assignment',sql(`SELECT (SELECT count(*) FROM tenant_members WHERE tenant_id=10059 AND user_id=${literal(user.id)} AND deleted_at IS NULL), (SELECT count(*) FROM workbench_access_assignments WHERE tenant_id=10059 AND user_id=${literal(user.id)})`)==='1|1')
await api(`/access/users/${user.id}`,'DELETE');userDeleted=true
await api('/access/me','GET',undefined,member,[401,403])
check('delete_removes_account_from_list',!(await api('/access/users')).data.some(u=>u.id===user.id))
check('delete_removes_membership_and_assignments',sql(`SELECT (SELECT count(*) FROM tenant_members WHERE user_id=${literal(user.id)} AND deleted_at IS NULL), (SELECT count(*) FROM tenant_members WHERE tenant_id=10059 AND user_id=${literal(user.id)} AND deleted_at IS NOT NULL), (SELECT count(*) FROM workbench_access_assignments WHERE user_id=${literal(user.id)})`)==='0|1|0')
}
if(require.main===module)main().catch(e=>{failures.push(e.message);process.exitCode=1}).finally(()=>finishVerification({browser,cleanup,failures,writeReport:()=>{
fs.writeFileSync(`${directory}/${reportName}.json`,JSON.stringify({started_at:startedAt,time:new Date().toISOString(),origin,api_origin:new URL(base).origin,checks,failures,passed:failures.length===0},null,2))
console.log(JSON.stringify({checks,failures,passed:failures.length===0}))
if(failures.length)process.exitCode=1
}}))
module.exports={validateResponse,trackCreatedResource,finishVerification}