member-groups.test.mjs 5.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import crypto from 'node:crypto';
  5. import { createApplication } from '../src/core.mjs';
  6. import { attachMemberGroupHandlers, readMemberGroupBoard } from '../src/member-groups.mjs';
  7. const data = JSON.parse(fs.readFileSync(new URL('../references/runtime-data.json',import.meta.url)));
  8. function fixture() {
  9. let rows={KsfRuntimeConfig:[{objectId:'runtime',key:'runtime',value:{signingSecret:'PRIVATE',wecomDirectory:{secret:'PRIVATE'}}}]},seq=0,queue=Promise.resolve();
  10. const clone=structuredClone;
  11. const store={
  12. async transaction(fn) { const exec=async()=>{const before=clone(rows);try{return await fn(store);}catch(e){rows=before;throw e;}};const result=queue.then(exec);queue=result.catch(()=>{});return result; },
  13. async lock(){},
  14. async list(name,{where={}}={}) {return clone((rows[name]||[]).filter((r)=>Object.entries(where).every(([k,v])=>r[k]===v)));},
  15. async create(name,value){const row={...clone(value),objectId:'object'+ ++seq};(rows[name]||=[]).push(row);return clone(row);},
  16. async update(name,id,changes){const row=rows[name].find((r)=>r.objectId===id);Object.assign(row,clone(changes));return clone(row);},
  17. };
  18. const board={session:[{objectId:'s1',studentName:'同名',groupId:'G1',meta:{participantKey:'participant:one'}},{objectId:'s2',studentName:'同名',groupId:'G2',meta:{participantKey:'participant:two'}}],artifact:[],project:[],opportunity:[]};
  19. const app=createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'test'},files:{}});
  20. attachMemberGroupHandlers(app,{store,loadBoard:async()=>board});
  21. const call=async(name,input={})=>(await app.dispatch(name,input,null)).data;
  22. const change=(revision,command,requestId=crypto.randomUUID())=>call('ksfManagePublicMemberGroups',{requestId,expectedRevision:revision,...command});
  23. return {store,board,call,change};
  24. }
  25. test('公开分组只读不会写库或泄露运行配置,首次沿用现有组号',async()=>{
  26. const f=fixture(),r=await f.call('ksfGetPublicMemberGroups');
  27. assert.equal(r.revision,0);assert.equal(r.groups.length,2);assert.equal(r.memberCount,2);
  28. assert.equal((await f.store.list('KsfRuntimeConfig')).length,1);assert.ok(!JSON.stringify(r).includes('PRIVATE'));
  29. });
  30. test('共享分组创建/批量分配/重命名/删除持久化且不改业务会话',async()=>{
  31. const f=fixture(),before=JSON.stringify(f.board);
  32. let r=await f.change(0,{action:'create',name:'研发组'});const id=r.groups.at(-1).id;
  33. r=await f.change(r.revision,{action:'assign',groupId:id,memberIds:['participant:one','participant:two']});
  34. assert.equal(Object.values(r.assignments).filter((v)=>v===id).length,2);
  35. r=await f.change(r.revision,{action:'rename',groupId:id,name:'创新组'});assert.equal(r.groups.at(-1).name,'创新组');
  36. r=await f.change(r.revision,{action:'delete',groupId:id});assert.deepEqual(r.assignments,{});
  37. assert.equal(JSON.stringify(f.board),before);assert.equal((await f.call('ksfGetPublicMemberGroups')).revision,4);
  38. assert.equal((await f.store.list('KsfOperation')).length,4);assert.equal((await f.store.list('KsfRuntimeConfig')).find((x)=>x.key==='runtime').value.signingSecret,'PRIVATE');
  39. });
  40. test('同请求幂等、旧版本和并发更新不会覆盖其他人的更改',async()=>{
  41. const f=fixture(),command={action:'create',name:'研发组'};
  42. const first=await f.change(0,command,'same');assert.deepEqual(await f.change(0,command,'same'),first);
  43. await assert.rejects(()=>f.change(0,{...command,name:'另一组'},'same'),{code:'IDEMPOTENCY_CONFLICT'});
  44. const results=await Promise.allSettled([f.change(1,{action:'create',name:'A组'}),f.change(1,{action:'create',name:'B组'})]);
  45. assert.equal(results.filter((r)=>r.status==='fulfilled').length,1);assert.equal(results.find((r)=>r.status==='rejected').reason.code,'VERSION_CONFLICT');
  46. assert.equal((await f.store.list('KsfOperation')).length,2);
  47. });
  48. test('隐藏成员、越权参数、重复名称和部分无效批次全部拒绝且不留半批结果',async()=>{
  49. const f=fixture();
  50. for(const command of [{action:'create',name:'G1'},{action:'create',name:'G1',groupId:'legacy-group:G1'},
  51. {action:'assign',groupId:'legacy-group:G1',memberIds:['participant:one','private:unknown']},
  52. {action:'assign',groupId:null,memberIds:['participant:one'],role:'admin'}]) await assert.rejects(()=>f.change(0,command),{code:'VALIDATION_FAILED'});
  53. assert.equal((await f.call('ksfGetPublicMemberGroups')).revision,0);assert.equal((await f.store.list('KsfOperation')).length,0);
  54. });
  55. test('公开成员读取完整分页且不带服务凭据,读取失败不当空成员',async()=>{
  56. let count=0;
  57. const board=await readMemberGroupBoard({url:'/fixture',appId:'public',fetchImpl:async(url,options)=>{
  58. count++;assert.deepEqual(Object.keys(options.headers).sort(),['Content-Type','X-Parse-Application-Id']);
  59. const cursor=JSON.parse(options.body).params.cursor;
  60. return {ok:true,json:async()=>({result:{ok:true,data:{session:[{objectId:cursor?'s2':'s1'}],project:[],artifact:[],opportunity:[],nextCursor:cursor?null:'next'}}})};
  61. }});assert.equal(count,2);assert.equal(board.session.length,2);
  62. await assert.rejects(()=>readMemberGroupBoard({url:'/fixture',appId:'public',fetchImpl:async()=>({ok:false,json:async()=>({})})}),{code:'MEMBER_READ_FAILED'});
  63. });