member-groups.test.mjs 6.3 KB

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