smoke-operator-accounts.mjs 8.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. #!/usr/bin/env node
  2. // All mutations target newly created fixtures. No existing account is edited.
  3. import {randomBytes} from 'node:crypto';
  4. import {mkdirSync,writeFileSync} from 'node:fs';
  5. 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';
  6. if(!process.env.XIAOSHU_MASTER_KEY)throw Error('Missing master key');
  7. 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});
  8. 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;}
  9. 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;}
  10. const expect=(truth,label)=>{if(!truth)throw Error(label);console.log('PASS '+label);};
  11. async function denied(fn,label){try{await fn();}catch{console.log('PASS '+label);return;}throw Error(label+' unexpectedly allowed');}
  12. 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;};
  13. 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;}
  14. let actor;
  15. try{
  16. actor=await seed('legacy');const token=actor.sessionToken;
  17. // Master-authorized test provisioning; public Parse registration cannot assign roles.
  18. if(!/^[A-Za-z0-9]+$/.test(actor.objectId))throw Error('Invalid fixture id');
  19. const helperPath='xiaoshu/system/operator-fixture-'+id;
  20. 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 $$;";
  21. 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}});}`;
  22. const helper=await parse('/classes/Function','POST',{name:helperPath,path:helperPath,code:helperCode,enabled:true,company:{__type:'Pointer',className:'Company',objectId:co}});
  23. try{await call(token,'',{},helperPath.replace('xiaoshu/',''));}finally{await parse('/classes/Function/'+helper.objectId,'DELETE');}
  24. const meta=await call(token,'meta');expect(meta.identity.operationsRole==='ops-manager'&&!meta.identity.isSuperAdmin,'旧 admin 会话识别运营经理且未提升为超级管理员');
  25. await call(token,'ops/dashboard/summary',{},'ops/gateway-v3');console.log('PASS 旧管理员可通过运营网关权限校验');
  26. const accountName='operator_smoke_'+id+'_staff';
  27. 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});
  28. 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,'新增账号及姓名角色查询');
  29. await denied(()=>call(token,'operatorAccounts/create',{reason:'重复账号验收',payload:{username:accountName,displayName:'重复',roleKey:'ops-staff',password}}),'重复登录账号被拒绝');
  30. const logged=await login(accountName);expect((await call(logged.sessionToken,'meta')).identity.operationsRole==='ops-staff','新账号可登录且角色正确');
  31. await denied(()=>parse('/users/'+created.objectId,'PUT',{adminRoleKey:'super-admin',roles:['super-admin']},logged.sessionToken),'普通账号不能直接通过 Parse 修改权限字段');
  32. await denied(()=>call(logged.sessionToken,'operatorAccounts/list'),'普通运营人员不能管理账号');
  33. await denied(()=>call(token,'operatorAccounts/save',{targetId:created.objectId,reason:'越权验收',payload:{displayName:'测试',roleKey:'super-admin'}}),'经理不能授予超级管理员');
  34. await call(token,'operatorAccounts/save',{targetId:created.objectId,expectedUpdatedAt:row.updatedAt,reason:'角色变更验收',payload:{displayName:'审计测试',roleKey:'ops-auditor'}});
  35. await denied(()=>call(logged.sessionToken,'meta'),'角色变更使旧会话失效');
  36. row=(await call(token,'operatorAccounts/list',{search:accountName})).items[0];expect(row.roleKey==='ops-auditor','角色变更保存正确');
  37. await denied(()=>call(token,'operatorAccounts/status',{targetId:actor.objectId,reason:'本人保护',payload:{disabled:true}}),'不能停用当前登录账号');
  38. await call(token,'operatorAccounts/reset-password',{targetId:row.objectId,expectedUpdatedAt:row.updatedAt,reason:'密码重置验收',payload:{password:nextPassword}});
  39. await denied(()=>login(accountName,password),'旧密码失效');const newLogin=await login(accountName,nextPassword);expect((await call(newLogin.sessionToken,'meta')).identity.operationsRole==='ops-auditor','新密码登录并保留角色');
  40. 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'),'停用撤销登录会话');
  41. 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}});
  42. const restored=await login(accountName,nextPassword);await call(token,'operatorAccounts/revoke-sessions',{targetId:row.objectId,reason:'退出会话验收'});await denied(()=>call(restored.sessionToken,'meta'),'主动退出旧会话生效');
  43. 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),'审计不包含密码');
  44. console.log('OPERATOR_SMOKE_PASSED');
  45. }finally{
  46. // Privileged SQL cleanup is restricted to these exact disposable ids; direct
  47. // user deletion must not bypass last-administrator protection.
  48. const cleaner=await parse('/users','POST',{username:'operator_cleanup_'+id,password,company:{__type:'Pointer',className:'Company',objectId:co}});
  49. let helper;
  50. try{
  51. if(users.some(uid=>!/^[A-Za-z0-9]+$/.test(uid)))throw Error('Invalid fixture id');
  52. const ids=users.map(uid=>"'"+uid+"'").join(',')||"''";
  53. 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 $$;";
  54. 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}});}`;
  55. helper=await parse('/classes/Function','POST',{name:path,path,code,enabled:true,company:{__type:'Pointer',className:'Company',objectId:co}});
  56. await call(cleaner.sessionToken,'',{},path.replace('xiaoshu/',''));console.log('OPERATOR_FIXTURES_CLEANED');
  57. }finally{if(helper)await parse('/classes/Function/'+helper.objectId,'DELETE');await parse('/users/'+cleaner.objectId,'DELETE');}
  58. }