| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889 |
- import {test} from 'node:test';
- import assert from 'node:assert/strict';
- import {readFileSync} from 'node:fs';
- import vm from 'node:vm';
- import {PGlite} from '@electric-sql/pglite';
- const read=p=>readFileSync(new URL(p,import.meta.url),'utf8');
- const source=read('../deploy-admin-functions.mjs');
- const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
- const policy=read('../cloud/admin-role-policy.js'),scope=read('../cloud/store-scope.js');
- const plain=v=>JSON.parse(JSON.stringify(v));
- 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;}
- function user(id,attrs){return{id,get:k=>attrs[k],fetch:async()=>{},toJSON:()=>({objectId:id,...attrs})};}
- const company={id:'co'},account=user('store-a',{company,identityType:'store',legacyUserId:10,legacyGroupId:2});
- const ctx={current:account,company,storeId:10,isSuperAdmin:false,roles:['store-admin']};
- const member={objectId:'member-a',company:'co',identityType:'member',legacyUserData:{ParentUserID:10}};
- test('门店身份独立,伪造帐套或总部角色不能拓宽权限',async()=>{
- 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);
- const mixed=user('mixed',{company,identityType:'store',legacyUserId:10,roles:['super-admin'],isAdmin:true});assert.equal((await c.auth({user:mixed})).isSuperAdmin,false);
- assert.equal(c.storeAccountSnapshot(user('member',{identityType:'member',legacyGroupId:2,legacyUserId:20})),0);
- await assert.rejects(c.auth({user:user('disabled',{company,identityType:'store',legacyUserId:10,isDisabled:true})}),/停用/);
- await assert.rejects(c.auth({user:user('orphan',{identityType:'store',legacyUserId:10})}),/帐套/);
- });
- test('门店接口默认拒绝总部、工资、通用数据、同步与未经审核的新接口',async()=>{
- 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},{}),/无权/);
- });
- test('新增学生强制本店归属,拒绝伪造跨店、跨帐套及直接差错增额',async()=>{
- 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);
- await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/create'},{parentUserId:99}),/其他门店/);
- await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/list',companyId:'other'},{}),/帐套/);
- await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/credit-preview'},{direction:'adjust'}),/只能/);
- });
- test('按 objectId 和学生业务编号逐项核验,外店同帐套也不可访问',async()=>{
- const c=env();c.Psql={oneOrNone:async(_sql,args)=>args[1]==='20'||args[1]==='member-a'?member:{...member,objectId:'member-b',legacyUserData:{ParentUserID:11}}};
- for(const operation of ['ops/users/detail','ops/users/credit-ledger','ops/users/update-profile','ops/users/reset-password']){
- await c.prepareStoreRequest(ctx,{operation,objectId:'member-a'},{});await assert.rejects(c.prepareStoreRequest(ctx,{operation,targetId:'member-b'},{}),/不属于本店/);
- }
- 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}),/不属于本店/);
- await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/stores/credit-accounts',objectId:'store-b'},{}),/门店不存在/);
- });
- test('修改预约先核验原预约,再进入幂等结果;不能拿自己的学生覆盖外店预约',async()=>{
- const c=env(),calls=[];c.storeAssertMember=async()=>{};c.scopedObject=async(_class,id)=>{calls.push(id);throw Error('原预约属于外店');};
- await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/appointments/update',targetId:'other-appointment',idempotencyKey:'old-key'},{studentId:20}),/外店/);assert.deepEqual(calls,['other-appointment']);
- });
- test('预约和开课对象按关联学生校验,其他模型默认拒绝',async()=>{
- const c=env();c.Psql={oneOrNone:async()=>({uid:21})};c.storeAssertMember=async(_c,_i,id)=>{assert.equal(id,21);throw Error('外店学生');};
- for(const modelId of [54,58])await assert.rejects(c.storeAssertObject(ctx,{},'CommonModel',user('c',{modelId,itemId:1})),/外店/);
- await assert.rejects(c.storeAssertObject(ctx,{},'CommonModel',user('c',{modelId:52})),/无权/);
- });
- test('老师、学生和门店单目标查询不跨店,停用老师不可排课',async()=>{
- 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);
- });
- test('课酬与跨店冲突原单编号不传给门店,保留计费规则',()=>{
- 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);
- });
- test('课程列表先按门店过滤再统计分页,不能通过搜索或翻页看到外店',async()=>{
- 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);
- INSERT INTO sample("objectId","targetId","studentRealName","studentLegacy","commonUpdatedAt") VALUES ('a','a','本店','{"ParentUserID":10}',now()),('b','b','外店','{"ParentUserID":11}',now());`);
- 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);
- }finally{await db.close();}
- });
- test('事务内重新验证门店、学生、老师与原单,跨店或停用失败后余额不变',async()=>{
- 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);
- 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);
- 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}');
- 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'));
- 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]);
- 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'),/账号无效/);
- 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);
- }finally{await db.close();}
- });
- test('运营写入将权限复核和实际事务放在同一数据库事务内',async()=>{
- 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);
- });
- test('直接 REST 权限关闭,保留既有字段保护规则',async()=>{
- const {STORE_PRIVATE_CLASSES,privateClassPermissions}=await import('../store-access-policy.mjs');
- for(const name of ['_User','_Session','CommonModel','CourseAppointment','CourseBinding','MemoryPracticeRecord','LessonRecord','CreditTransaction'])assert.ok(STORE_PRIVATE_CLASSES.includes(name));
- const result=privateClassPermissions({find:{'*':true},update:{requiresAuthentication:true},protectedFields:{'*':['password']}});
- for(const operation of ['find','get','count','create','update','delete','addField'])assert.deepEqual(result[operation],{});
- assert.deepEqual(result.protectedFields,{'*':['password']});
- });
- test('门店元数据可以读取,技术后台和旧移动业务入口拒绝门店',async()=>{
- const admin=source.match(/const adminGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c={Date,console};vm.createContext(c);vm.runInContext(admin+'\n'+policy,c);
- const attrs={company,identityType:'store',legacyUserId:10,legacyGroupId:2},a=user('store-a',attrs);let status=200,out;
- const response={status:n=>{status=n;return response;},json:v=>{out=v;return v;}};
- c.Parse={Query:class{notEqualTo(){} async count(){throw Error('store must not read functions');}}};
- 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);
- await c.handler({user:a,body:{params:{operation:'catalog'}}},response);assert.equal(status,403);
- const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];assert.ok(app.includes("门店业务请使用门店管理后台"));
- });
|