#!/usr/bin/env node // All mutations target newly created fixtures. No existing account is edited. import {randomBytes} from 'node:crypto'; import {mkdirSync,writeFileSync} from 'node:fs'; const base=process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro',app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx'; if(!process.env.XIAOSHU_MASTER_KEY)throw Error('Missing master key'); const id=Date.now()+'_'+randomBytes(4).toString('hex'),password=randomBytes(18).toString('base64url'),nextPassword=randomBytes(18).toString('base64url'),users=[],dir='tmp/operator-smoke-'+id;mkdirSync(dir,{recursive:true,mode:0o700}); async function parse(path,method='GET',body,session){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,...(session?{'X-Parse-Session-Token':session}:{'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY})},...(body?{body:JSON.stringify(body)}:{})});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||'HTTP '+r.status);return d;} async function call(token,operation,params={},gateway='admin/gateway'){const r=await fetch(base+'/api/functions/xiaoshu/'+gateway,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation,...params}})}),d=await r.json();if(!r.ok||!d.success)throw Error(d.message||d.error||'HTTP '+r.status);return d.data;} const expect=(truth,label)=>{if(!truth)throw Error(label);console.log('PASS '+label);}; async function denied(fn,label){try{await fn();}catch{console.log('PASS '+label);return;}throw Error(label+' unexpectedly allowed');} const login=async(username,pass=password)=>{const r=await fetch(base+'/parse/login?'+new URLSearchParams({username,password:pass}),{headers:{'X-Parse-Application-Id':app}}),d=await r.json();if(!r.ok)throw Error('Login rejected');return d;}; async function seed(suffix,fields={}){const u=await parse('/users','POST',{username:'operator_smoke_'+id+'_'+suffix,password,company:{__type:'Pointer',className:'Company',objectId:co},...fields});users.push(u.objectId);writeFileSync(dir+'/fixture.json',JSON.stringify({users,actor:users[0]}),{mode:0o600});return u;} let actor; try{ actor=await seed('legacy');const token=actor.sessionToken; // Master-authorized test provisioning; public Parse registration cannot assign roles. if(!/^[A-Za-z0-9]+$/.test(actor.objectId))throw Error('Invalid fixture id'); const helperPath='xiaoshu/system/operator-fixture-'+id; const grantSql="DO $$ BEGIN PERFORM set_config('xiaoshu.operator_write','on',true); UPDATE \"_User\" SET \"isAdmin\"=true,\"role\"='admin',\"roles\"='[\"admin\"]'::jsonb WHERE \"objectId\"='"+actor.objectId+"'; END $$;"; const helperCode=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(actor.objectId)})return response.status(403).json({success:false});await Psql.none(${JSON.stringify(grantSql)});return response.json({success:true,data:{ready:true}});}`; const helper=await parse('/classes/Function','POST',{name:helperPath,path:helperPath,code:helperCode,enabled:true,company:{__type:'Pointer',className:'Company',objectId:co}}); try{await call(token,'',{},helperPath.replace('xiaoshu/',''));}finally{await parse('/classes/Function/'+helper.objectId,'DELETE');} const meta=await call(token,'meta');expect(meta.identity.operationsRole==='ops-manager'&&!meta.identity.isSuperAdmin,'旧 admin 会话识别运营经理且未提升为超级管理员'); await call(token,'ops/dashboard/summary',{},'ops/gateway-v3');console.log('PASS 旧管理员可通过运营网关权限校验'); const accountName='operator_smoke_'+id+'_staff'; const created=await call(token,'operatorAccounts/create',{reason:'独立账号验收',payload:{username:accountName,displayName:'运营账号测试',roleKey:'ops-staff',password}});users.push(created.objectId);writeFileSync(dir+'/fixture.json',JSON.stringify({users,actor:users[0]}),{mode:0o600}); const list=await call(token,'operatorAccounts/list',{search:accountName});let row=list.items.find(r=>r.objectId===created.objectId);expect(row&&row.roleKey==='ops-staff'&&!row.disabled,'新增账号及姓名角色查询'); await denied(()=>call(token,'operatorAccounts/create',{reason:'重复账号验收',payload:{username:accountName,displayName:'重复',roleKey:'ops-staff',password}}),'重复登录账号被拒绝'); const logged=await login(accountName);expect((await call(logged.sessionToken,'meta')).identity.operationsRole==='ops-staff','新账号可登录且角色正确'); await denied(()=>parse('/users/'+created.objectId,'PUT',{adminRoleKey:'super-admin',roles:['super-admin']},logged.sessionToken),'普通账号不能直接通过 Parse 修改权限字段'); await denied(()=>call(logged.sessionToken,'operatorAccounts/list'),'普通运营人员不能管理账号'); await denied(()=>call(token,'operatorAccounts/save',{targetId:created.objectId,reason:'越权验收',payload:{displayName:'测试',roleKey:'super-admin'}}),'经理不能授予超级管理员'); await call(token,'operatorAccounts/save',{targetId:created.objectId,expectedUpdatedAt:row.updatedAt,reason:'角色变更验收',payload:{displayName:'审计测试',roleKey:'ops-auditor'}}); await denied(()=>call(logged.sessionToken,'meta'),'角色变更使旧会话失效'); row=(await call(token,'operatorAccounts/list',{search:accountName})).items[0];expect(row.roleKey==='ops-auditor','角色变更保存正确'); await denied(()=>call(token,'operatorAccounts/status',{targetId:actor.objectId,reason:'本人保护',payload:{disabled:true}}),'不能停用当前登录账号'); await call(token,'operatorAccounts/reset-password',{targetId:row.objectId,expectedUpdatedAt:row.updatedAt,reason:'密码重置验收',payload:{password:nextPassword}}); await denied(()=>login(accountName,password),'旧密码失效');const newLogin=await login(accountName,nextPassword);expect((await call(newLogin.sessionToken,'meta')).identity.operationsRole==='ops-auditor','新密码登录并保留角色'); row=(await call(token,'operatorAccounts/list',{search:accountName})).items[0];await call(token,'operatorAccounts/status',{targetId:row.objectId,expectedUpdatedAt:row.updatedAt,reason:'停用验收',payload:{disabled:true}});await denied(()=>call(newLogin.sessionToken,'meta'),'停用撤销登录会话'); row=(await call(token,'operatorAccounts/list',{search:accountName,status:'disabled'})).items[0];expect(row?.disabled,'停用筛选正确');await call(token,'operatorAccounts/status',{targetId:row.objectId,expectedUpdatedAt:row.updatedAt,reason:'恢复验收',payload:{disabled:false}}); const restored=await login(accountName,nextPassword);await call(token,'operatorAccounts/revoke-sessions',{targetId:row.objectId,reason:'退出会话验收'});await denied(()=>call(restored.sessionToken,'meta'),'主动退出旧会话生效'); const audit=await call(token,'operatorAccounts/audit',{targetId:row.objectId});expect(audit.items.some(a=>a.action==='reset-password')&&audit.items.some(a=>a.action==='save')&&audit.items.some(a=>a.action==='create'),'创建角色密码操作都有审计');expect(!JSON.stringify(audit).includes(password)&&!JSON.stringify(audit).includes(nextPassword),'审计不包含密码'); console.log('OPERATOR_SMOKE_PASSED'); }finally{ // Privileged SQL cleanup is restricted to these exact disposable ids; direct // user deletion must not bypass last-administrator protection. const cleaner=await parse('/users','POST',{username:'operator_cleanup_'+id,password,company:{__type:'Pointer',className:'Company',objectId:co}}); let helper; try{ if(users.some(uid=>!/^[A-Za-z0-9]+$/.test(uid)))throw Error('Invalid fixture id'); const ids=users.map(uid=>"'"+uid+"'").join(',')||"''"; const sql="DO $$ BEGIN PERFORM set_config('xiaoshu.operator_write','on',true); DELETE FROM \"OperatorAccountAudit\" WHERE \"actorId\" IN ("+ids+"); DELETE FROM \"_Session\" WHERE \"user\" IN ("+ids+"); DELETE FROM \"_User\" WHERE \"objectId\" IN ("+ids+"); END $$;"; const path='xiaoshu/system/operator-cleanup-'+id,code=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(cleaner.objectId)})return response.status(403).json({success:false});await Psql.none(${JSON.stringify(sql)});return response.json({success:true,data:{cleaned:true}});}`; helper=await parse('/classes/Function','POST',{name:path,path,code,enabled:true,company:{__type:'Pointer',className:'Company',objectId:co}}); await call(cleaner.sessionToken,'',{},path.replace('xiaoshu/',''));console.log('OPERATOR_FIXTURES_CLEANED'); }finally{if(helper)await parse('/classes/Function/'+helper.objectId,'DELETE');await parse('/users/'+cleaner.objectId,'DELETE');} }