account-security.test.mjs 7.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243
  1. import {test} from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
  2. const code=readFileSync(new URL('../cloud/account-security.js',import.meta.url),'utf8');
  3. const gatewaySource=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
  4. test('会员姓名和绑定手机号解析到同一账号,昵称不参与登录,同名要求手机号',async()=>{
  5. const db=new PGlite();try{
  6. await db.exec(`CREATE TABLE "_User"("objectId" text,"username" text,"company" text,"realName" text,"mobile" text,"phone" text,"legacyUserId" numeric,"legacyUserData" jsonb,"isDeleted" boolean);INSERT INTO "_User" VALUES('u1','account-one','co','可爱同学','13800000001',NULL,1,'{"TrueName":"可爱同学","HoneyName":"可改昵称"}',false),('other','another','other-company','可爱同学','13800000009',NULL,9,'{}',false);`);
  7. const c=vm.createContext({DEFAULT_COMPANY_ID:'co',Psql:{query:async(sql,args)=>(await db.query(sql,args)).rows},legacyLoginResult:()=>({}),number:v=>Number(v)||0,firstValue:(...a)=>a.find(v=>v!==undefined&&v!==null&&v!==''),fail:(status,message)=>{throw Object.assign(Error(message),{status})},Parse:{Query:class{async get(id){return{id}}}}});
  8. vm.runInContext(gatewaySource.slice(gatewaySource.indexOf('async function migratedLoginTarget('),gatewaySource.indexOf('const READING_CLASSES=')),c);
  9. assert.equal((await c.migratedLoginTarget('可爱同学')).id,'u1');assert.equal((await c.migratedLoginTarget('13800000001')).id,'u1');assert.equal(await c.migratedLoginTarget('可改昵称'),null);assert.equal(await c.migratedLoginTarget("' OR 1=1 --"),null);
  10. await db.exec(`INSERT INTO "_User" VALUES('u2','account-two','co','可爱同学','13800000002',NULL,2,'{}',false);`);
  11. await assert.rejects(c.migratedLoginTarget('可爱同学'),/姓名重复/);assert.equal((await c.migratedLoginTarget('13800000001')).id,'u1');
  12. await db.exec(`UPDATE "_User" SET "isDeleted"=true WHERE "objectId"='u2'`);assert.equal((await c.migratedLoginTarget('可爱同学')).id,'u1');
  13. }finally{await db.close();}
  14. });
  15. test('创建会员由服务端统一设置初始密码,编辑资料不会重置密码',()=>{
  16. const fn=gatewaySource.split('\n').find(line=>line.startsWith('function memberProfilePayload('));const c=vm.createContext({text:v=>String(v||'').trim(),number:v=>Number(v)||0,fail:(_,m)=>{throw Error(m)}});vm.runInContext(fn,c);
  17. const profile={displayName:'新同学',mobile:'13800000001'};assert.equal(c.memberProfilePayload(profile,true).password,'12345678');assert.equal(c.memberProfilePayload({...profile,password:'caller-selected-password'},true).password,'12345678');assert.equal(c.memberProfilePayload(profile,false).password,'');
  18. });
  19. test('学生修改昵称时不能同时改动绑定姓名和手机号',async()=>{
  20. const attrs={legacyGroupId:1,realName:'会员姓名',mobile:'13800000001',nickname:'旧昵称',legacyUserData:{TrueName:'会员姓名',Mobile:'13800000001',HoneyName:'旧昵称'}};let saved=0;const current={get:k=>attrs[k],set:(k,v)=>attrs[k]=v,save:async()=>saved++};
  21. const c=vm.createContext({ownLegacyId:()=>1,number:v=>Number(v)||0,isAdminUser:()=>false,isMemberAccount:()=>true,parseObject:v=>v||{},fieldsOf:async()=>({realName:{},mobile:{},nickname:{}}),legacyData:u=>u.get('legacyUserData'),writeValue:v=>v,cleanText:v=>v,legacyUser:()=>({}),legacyUserAddon:()=>({})});
  22. vm.runInContext(gatewaySource.slice(gatewaySource.indexOf('async function updateUserProfile('),gatewaySource.indexOf('function legacyUserRow(')),c);
  23. await c.updateUserProfile({mu:{trueName:'篡改姓名',mobile:'13900000000',honeyName:'新昵称'}},current);assert.equal(saved,1);assert.equal(attrs.realName,'会员姓名');assert.equal(attrs.mobile,'13800000001');assert.equal(attrs.legacyUserData.TrueName,'会员姓名');assert.equal(attrs.legacyUserData.Mobile,'13800000001');assert.equal(attrs.nickname,'新昵称');assert.equal(attrs.legacyUserData.HoneyName,'新昵称');
  24. });
  25. test('旧分组为 3 的已确认会员可以由管理员重置,陪练身份仍被拒绝',async()=>{
  26. const values={legacyGroupId:3,identityType:'member',legacyUserId:22};const target={get:k=>values[k],toJSON:()=>values};
  27. const c=vm.createContext({scopedObject:async()=>target,number:v=>Number(v)||0,displayName:()=> '会员姓名',pointerId:()=> 'co',Psql:{oneOrNone:async()=>null},fail:(_,m)=>{throw Error(m)}});
  28. vm.runInContext(gatewaySource.split('\n').find(line=>line.startsWith('async function memberTarget(')),c);
  29. assert.equal(await c.memberTarget({company:{}},{},'student'),target);values.identityType='coach';await assert.rejects(c.memberTarget({company:{}},{},'coach'),/身份已发生变化/);
  30. });
  31. test('旧密码只允许显式首次迁移,已重置和历史不明账号拒绝且不调用旧系统',async()=>{
  32. for(const state of [{},{legacyPasswordMigrationAllowed:false},{legacyPasswordMigrationAllowed:true,passwordResetRequired:true}]){
  33. let verified=0,saved=0;const c=vm.createContext({migratedLoginTarget:async()=>({get:k=>state[k],save:async()=>saved++}),verifyLegacyPassword:async()=>verified++,fail:(status,msg)=>{throw Error(msg)}});vm.runInContext(code,c);await assert.rejects(c.migrateLegacyLogin('a','old'),/密码错误/);assert.equal(saved,0);assert.equal(verified,0);
  34. }
  35. });
  36. test('一次性迁移核对身份并携带验证前密码版本,不能切换账号',async()=>{
  37. const v={legacyPasswordMigrationAllowed:true,credentialRevision:3,username:'a'},sets={};const target={id:'a',get:k=>v[k],set:(k,x)=>sets[k]=x,setPassword:x=>sets.password=x,save:async()=>{}};
  38. const c=vm.createContext({migratedLoginTarget:async()=>target,verifyLegacyPassword:async()=>({}),Parse:{User:{logIn:async()=>({ok:true})}},fail:(_,m)=>{throw Error(m)}});vm.runInContext(code,c);await c.migrateLegacyLogin('a','old');assert.equal(sets.credentialExpectedRevision,3);assert.equal(sets.credentialMigrationAttempt,true);
  39. let calls=0;c.migratedLoginTarget=async()=>++calls===1?target:{...target,id:'b'};await assert.rejects(c.migrateLegacyLogin('a','old'),/验证未通过/);
  40. });
  41. test('数据库密码变化原子关闭回退、撤销会话,拒绝并发旧版本及客户端重新启用',async()=>{
  42. const db=new PGlite();try{await db.exec(`CREATE TABLE "_User"("objectId" text primary key,company text,"_hashed_password" text,"createdAt" timestamptz default now());CREATE TABLE "_Session"("user" text);`);await db.exec(readFileSync(new URL('../sql/account-security.sql',import.meta.url),'utf8'));await db.exec(`INSERT INTO "_User"("objectId",company,"_hashed_password","legacyPasswordMigrationAllowed") VALUES('u','co','random',true);`);const one=async()=> (await db.query(`SELECT * FROM "_User"`)).rows[0];assert.equal((await one()).legacyPasswordMigrationAllowed,false);await db.query('SELECT xs_enable_initial_legacy_login($1,$2)',['co','u']);await db.exec(`INSERT INTO "_Session" VALUES('u');UPDATE "_User" SET "_hashed_password"='new',"passwordResetRequired"=true WHERE "objectId"='u';`);assert.equal((await one()).credentialRevision,1);assert.equal((await one()).legacyPasswordMigrationAllowed,false);assert.equal((await db.query('SELECT * FROM "_Session"')).rows.length,0);await assert.rejects(db.exec(`UPDATE "_User" SET "_hashed_password"='old',"credentialExpectedRevision"=0,"credentialMigrationAttempt"=true`),/密码已更新/);await assert.rejects(db.exec(`UPDATE "_User" SET "legacyPasswordMigrationAllowed"=true`),/禁止/);await assert.rejects(db.query('SELECT xs_enable_initial_legacy_login($1,$2)',['co','u']),/不符合/);assert.equal((await one())._hashed_password,'new');}finally{await db.close();}
  43. });