store-scope.test.mjs 11 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. import {test} from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import {readFileSync} from 'node:fs';
  4. import vm from 'node:vm';
  5. import {PGlite} from '@electric-sql/pglite';
  6. const read=p=>readFileSync(new URL(p,import.meta.url),'utf8');
  7. const source=read('../deploy-admin-functions.mjs');
  8. const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
  9. const policy=read('../cloud/admin-role-policy.js'),scope=read('../cloud/store-scope.js');
  10. const plain=v=>JSON.parse(JSON.stringify(v));
  11. function env(){const c={Date,console};vm.createContext(c);vm.runInContext(ops+'\n'+policy+'\n'+scope+'\n'+read('../cloud/member-enrollment.js'),c);return c;}
  12. function user(id,attrs){return{id,get:k=>attrs[k],fetch:async()=>{},toJSON:()=>({objectId:id,...attrs})};}
  13. const company={id:'co'},account=user('store-a',{company,identityType:'store',legacyUserId:10,legacyGroupId:2});
  14. const ctx={current:account,company,storeId:10,isSuperAdmin:false,roles:['store-admin']};
  15. const member={objectId:'member-a',company:'co',identityType:'member',legacyUserData:{ParentUserID:10}};
  16. test('门店身份独立,伪造帐套或总部角色不能拓宽权限',async()=>{
  17. const c=env();const a=await c.auth({user:account,company:{id:'other'}});assert.equal(a.storeId,10);assert.equal(a.company.id,'co');assert.equal(a.isSuperAdmin,false);
  18. const mixed=user('mixed',{company,identityType:'store',legacyUserId:10,roles:['super-admin'],isAdmin:true});assert.equal((await c.auth({user:mixed})).isSuperAdmin,false);
  19. assert.equal(c.storeAccountSnapshot(user('member',{identityType:'member',legacyGroupId:2,legacyUserId:20})),0);
  20. await assert.rejects(c.auth({user:user('disabled',{company,identityType:'store',legacyUserId:10,isDisabled:true})}),/停用/);
  21. await assert.rejects(c.auth({user:user('orphan',{identityType:'store',legacyUserId:10})}),/帐套/);
  22. });
  23. test('门店接口默认拒绝总部、工资、通用数据、同步与未经审核的新接口',async()=>{
  24. const c=env();for(const operation of ['ops/stores/credit-post','ops/stores/credit-grants/save','ops/stores/update','ops/payroll/list','ops/coaches/create','ops/users/change-group','ops/relations/save','ops/users/import-commit','ops/sync/run','ops/new/endpoint','ops/appointments/complete'])await assert.rejects(c.prepareStoreRequest(ctx,{operation},{}),/无权/);
  25. });
  26. test('新增学生强制本店归属,拒绝伪造跨店、跨帐套及直接差错增额',async()=>{
  27. const c=env(),input={operation:'ops/users/create',_storeId:999,filters:{parentUserId:99}},p={parentUserId:0};await c.prepareStoreRequest(ctx,input,p);assert.equal(p.parentUserId,10);assert.equal(input._storeId,10);assert.equal(input.filters.parentUserId,10);
  28. await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/create'},{parentUserId:99}),/其他门店/);
  29. await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/list',companyId:'other'},{}),/帐套/);
  30. await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/credit-preview'},{direction:'adjust'}),/只能/);
  31. });
  32. test('按 objectId 和学生业务编号逐项核验,外店同帐套也不可访问',async()=>{
  33. const c=env();c.Psql={oneOrNone:async(_sql,args)=>args[1]==='20'||args[1]==='member-a'?member:{...member,objectId:'member-b',legacyUserData:{ParentUserID:11}}};
  34. for(const operation of ['ops/users/detail','ops/users/credit-ledger','ops/users/update-profile','ops/users/reset-password']){
  35. await c.prepareStoreRequest(ctx,{operation,objectId:'member-a'},{});await assert.rejects(c.prepareStoreRequest(ctx,{operation,targetId:'member-b'},{}),/不属于本店/);
  36. }
  37. for(const operation of ['ops/course-bindings/options','ops/course-bindings/save','ops/appointments/create'])await assert.rejects(c.prepareStoreRequest(ctx,{operation,studentId:21},{studentId:20}),/不属于本店/);
  38. await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/stores/credit-accounts',objectId:'store-b'},{}),/门店不存在/);
  39. });
  40. test('修改预约先核验原预约,再进入幂等结果;不能拿自己的学生覆盖外店预约',async()=>{
  41. const c=env(),calls=[];c.storeAssertMember=async()=>{};c.scopedObject=async(_class,id)=>{calls.push(id);throw Error('原预约属于外店');};
  42. await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/appointments/update',targetId:'other-appointment',idempotencyKey:'old-key'},{studentId:20}),/外店/);assert.deepEqual(calls,['other-appointment']);
  43. });
  44. test('预约和开课对象按关联学生校验,其他模型默认拒绝',async()=>{
  45. const c=env();c.Psql={oneOrNone:async()=>({uid:21})};c.storeAssertMember=async(_c,_i,id)=>{assert.equal(id,21);throw Error('外店学生');};
  46. for(const modelId of [54,58])await assert.rejects(c.storeAssertObject(ctx,{},'CommonModel',user('c',{modelId,itemId:1})),/外店/);
  47. await assert.rejects(c.storeAssertObject(ctx,{},'CommonModel',user('c',{modelId:52})),/无权/);
  48. });
  49. test('老师、学生和门店单目标查询不跨店,停用老师不可排课',async()=>{
  50. const c=env();let row={legacyUserId:30,identityType:'coach',legacyUserData:{ParentUserID:11}};c.Psql={oneOrNone:async()=>row};assert.equal(await c.userByLegacyId(ctx,{},30,3),null);row.legacyUserData.ParentUserID=10;assert.equal((await c.userByLegacyId(ctx,{},30,3)).legacyUserId,30);row.isDisabled=true;assert.equal(await c.userByLegacyId(ctx,{},30,3),null);row={legacyUserId:11};assert.equal(await c.userByLegacyId(ctx,{},11,2),null);
  51. });
  52. test('课酬与跨店冲突原单编号不传给门店,保留计费规则',()=>{
  53. const c=env(),result=c.storeSafeRules(ctx,{rules:[{teacherPay:45,creditCosts:[{account:'Purse',amount:1}]}],candidates:[{teachingRule:{teacherPay:45}}],conflicts:[{index:0,targetId:'secret',reason:'陪练时间冲突'}]});assert.equal(result.rules[0].teacherPay,undefined);assert.equal(result.rules[0].creditCosts[0].amount,1);assert.equal(result.conflicts[0].targetId,undefined);
  54. });
  55. test('课程列表先按门店过滤再统计分页,不能通过搜索或翻页看到外店',async()=>{
  56. const db=new PGlite();try{await db.exec(`CREATE TABLE sample("objectId" text,"targetId" text,"generalId" int,"studentObjectId" text,"studentRealName" text,"studentNickname" text,"studentUsername" text,"courseTitle" text,"studentLegacy" jsonb,"yhid" text,"kcid" text,"categoryKey" text,"enrollmentStatus" text,"syjd" text,"commonUpdatedAt" timestamptz);
  57. INSERT INTO sample("objectId","targetId","studentRealName","studentLegacy","commonUpdatedAt") VALUES ('a','a','本店','{"ParentUserID":10}',now()),('b','b','外店','{"ParentUserID":11}',now());`);
  58. const c=env();c.Psql={query:async(sql,args)=>(await db.query(sql,args)).rows};const result=await c.businessRecordSqlPage('course-bindings','SELECT * FROM sample',[],{_storeId:10,page:1,pageSize:1});assert.equal(result.total,1);assert.equal(result.items[0].objectId,'a');const other=await c.businessRecordSqlPage('course-bindings','SELECT * FROM sample',[],{_storeId:10,search:'外店'});assert.equal(other.total,0);
  59. }finally{await db.close();}
  60. });
  61. test('事务内重新验证门店、学生、老师与原单,跨店或停用失败后余额不变',async()=>{
  62. const db=new PGlite();try{await db.exec(`CREATE TABLE "_User"("objectId" text PRIMARY KEY,company text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"isDeleted" boolean,"legacyUserData" jsonb);
  63. CREATE TABLE "CommonModel"("objectId" text,company text,"modelId" int,"itemId" int);CREATE TABLE "CourseAppointment"(company text,id int,szyh text);CREATE TABLE "CourseBinding"(company text,id int,yhid text);
  64. INSERT INTO "_User" VALUES('store-a','co',10,2,'store',false,false,'{"Purse":10}'),('store-b','co',11,2,'store',false,false,'{}'),('member-a','co',20,1,'member',false,false,'{"ParentUserID":10,"Purse":2}'),('member-b','co',21,1,'member',false,false,'{"ParentUserID":11}'),('teacher-a','co',30,3,'coach',false,false,'{"ParentUserID":10}'),('teacher-b','co',31,3,'coach',false,false,'{"ParentUserID":11}');
  65. INSERT INTO "CommonModel" VALUES('appt-a','co',54,1),('appt-b','co',54,2);INSERT INTO "CourseAppointment" VALUES('co',1,'20'),('co',2,'21');`);await db.exec(read('../sql/store-scope.sql'));
  66. const guard=(actor,uid,coach=0,target='',co='co')=>db.query('SELECT xs_store_scope_guard($1,$2,$3,$4,$5)',[co,actor,uid,coach,target]);
  67. await guard('store-a',20,30,'appt-a');await assert.rejects(guard('store-a',21),/不属于本店/);await assert.rejects(guard('store-a',20,31),/陪练/);await assert.rejects(guard('store-a',20,30,'appt-b'),/原记录/);await assert.rejects(guard('store-a',20,30,'','other'),/账号无效/);
  68. await db.exec(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='store-a'`);await assert.rejects(guard('store-a',20),/停用/);assert.equal((await db.query(`SELECT "legacyUserData" FROM "_User" WHERE "objectId"='member-a'`)).rows[0].legacyUserData.Purse,2);
  69. }finally{await db.close();}
  70. });
  71. test('运营写入将权限复核和实际事务放在同一数据库事务内',async()=>{
  72. const c=env(),calls=[];c.Psql={tx:async fn=>fn({one:async sql=>{calls.push(sql);if(sql.includes('guard'))throw Error('学生已经转店');return{result:1};}})};await assert.rejects(c.enrollmentSql('SELECT write()',[],{context:ctx,studentId:20}),/转店/);assert.equal(calls.length,1);
  73. });
  74. test('直接 REST 权限关闭,保留既有字段保护规则',async()=>{
  75. const {STORE_PRIVATE_CLASSES,privateClassPermissions}=await import('../store-access-policy.mjs');
  76. for(const name of ['_User','_Session','CommonModel','CourseAppointment','CourseBinding','MemoryPracticeRecord','LessonRecord','CreditTransaction'])assert.ok(STORE_PRIVATE_CLASSES.includes(name));
  77. const result=privateClassPermissions({find:{'*':true},update:{requiresAuthentication:true},protectedFields:{'*':['password']}});
  78. for(const operation of ['find','get','count','create','update','delete','addField'])assert.deepEqual(result[operation],{});
  79. assert.deepEqual(result.protectedFields,{'*':['password']});
  80. });
  81. test('门店元数据可以读取,技术后台和旧移动业务入口拒绝门店',async()=>{
  82. const admin=source.match(/const adminGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c={Date,console};vm.createContext(c);vm.runInContext(admin+'\n'+policy,c);
  83. const attrs={company,identityType:'store',legacyUserId:10,legacyGroupId:2},a=user('store-a',attrs);let status=200,out;
  84. const response={status:n=>{status=n;return response;},json:v=>{out=v;return v;}};
  85. c.Parse={Query:class{notEqualTo(){} async count(){throw Error('store must not read functions');}}};
  86. await c.handler({user:a,body:{params:{operation:'meta'}}},response);assert.equal(status,200);assert.equal(out.data.identity.operationsRole,'store-admin');assert.equal(out.data.identity.isSuperAdmin,false);
  87. await c.handler({user:a,body:{params:{operation:'catalog'}}},response);assert.equal(status,403);
  88. const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];assert.ok(app.includes("门店业务请使用门店管理后台"));
  89. });