account-binding.test.mjs 6.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import crypto from 'node:crypto';
  4. import fs from 'node:fs';
  5. import { createApplication } from '../src/core.mjs';
  6. import { attachWorkshopHandlers } from '../src/workshop.mjs';
  7. import { attachAccountBindingHandlers } from '../src/account-binding.mjs';
  8. import { indexBoardMembers } from '../../src/core/member-groups.js';
  9. import { WORKSHOP_ROSTER } from '../../src/core/workshop-roster.js';
  10. const data=JSON.parse(fs.readFileSync(new URL('../references/runtime-data.json',import.meta.url)));
  11. const audit=JSON.parse(fs.readFileSync(new URL('../../docs/board-login-20261111/account-verification.json',import.meta.url)));
  12. const service={id:'trusted-service',kind:'service',scopes:['ingest:write','data:read'],projectIds:['*']};
  13. const key='participant-account:'+'a'.repeat(64),otherKey='participant-account:'+'b'.repeat(64);
  14. function fixture(){
  15. let rows={},seq=0,clock=Date.now(),current=audit.results.find(x=>x.username==='guopeng');
  16. let queue=Promise.resolve();
  17. const store={async lock(){},async transaction(fn){const run=async()=>{const before=structuredClone(rows);try{return await fn(store);}catch(e){rows=before;throw e;}};const r=queue.then(run);queue=r.catch(()=>{});return r;},
  18. async list(name,{where={},limit=10000}={}){return structuredClone((rows[name]||[]).filter(r=>Object.entries(where).every(([k,v])=>JSON.stringify(r[k])===JSON.stringify(v))).slice(0,limit));},
  19. async get(name,id){return structuredClone((rows[name]||[]).find(r=>r.objectId===id)||null);},
  20. async create(name,p){const r={...structuredClone(p),objectId:'object'+ ++seq};(rows[name]||=[]).push(r);return structuredClone(r);},
  21. async update(name,id,p){const r=(rows[name]||[]).find(x=>x.objectId===id);assert.ok(r);Object.assign(r,structuredClone(p));return structuredClone(r);}};
  22. const app=createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'fixture'},files:{}});
  23. attachWorkshopHandlers(app,{store,data});
  24. attachAccountBindingHandlers(app,{store,settings:{functionsUrl:'https://kangshifu.fmode.cn/api/functions'},now:()=>clock,
  25. fetchImpl:async(url,options)=>{assert.equal(url,'https://server.fmode.cn/parse/users/me');return{ok:options.headers['X-Parse-Session-Token']==='fixture-session',json:async()=>({objectId:current.userId,username:current.username,company:{__type:'Pointer',className:'Company',objectId:current.companyId||'FKieRbIoGt'}})};}});
  26. const call=async(name,p,actor=service)=>(await app.dispatch(name,p,actor)).data;
  27. const account=p=>call('ksfWorkshopAccount',{requestId:crypto.randomUUID(),...p});
  28. const prepare=(profile=key,username='guopeng')=>account({action:'prepare',participantProfileKey:profile,username,displayName:'果鹏'});
  29. const confirm=async link=>call('ksfWorkshopAccount',{action:'confirm',ticket:new URL(link.confirmationUrl).hash.slice(8)},await app.authenticateWorkshopPlatform({'X-Fmode-Session-Token':'fixture-session'}));
  30. const start=(profile=key,workshop='topic-one')=>call('ksfStartWorkshopSession',{requestId:crypto.randomUUID(),workshopKey:workshop,participant:{key:profile,name:'果鹏',identityBasis:'existing_record',account:{platform:'fmode',tenantId:'FKieRbIoGt',accountId:current.username,accountName:current.username}},agent:{key:'agent-account:'+'c'.repeat(64),name:'助手',identityBasis:'runtime_config',role:'workshop',runtimePlatform:'hermes',channel:'matrix',account:{platform:'matrix',accountId:'fixture-agent'}},conversation:{key:'fixture-room',channel:'matrix'},dataMode:'live'});
  31. return{store,app,call,account,prepare,confirm,start,setUser:user=>current=user,advance:ms=>clock+=ms};
  32. }
  33. test('首次绑定必须本人平台会话,未绑定提交被拦截且不创建成员或报告',async()=>{
  34. const f=fixture();await assert.rejects(()=>f.start(),{code:'ACCOUNT_BINDING_REQUIRED'});
  35. assert.equal((await f.store.list('KfSession')).length,0);
  36. const prepared=await f.prepare();assert.equal(prepared.status,'awaiting_account_confirmation');
  37. await assert.rejects(()=>f.call('ksfWorkshopAccount',{action:'confirm',ticket:new URL(prepared.confirmationUrl).hash.slice(8)}),{code:'AUTH_REQUIRED'});
  38. await assert.rejects(()=>f.app.authenticateWorkshopPlatform({}),{code:'AUTH_REQUIRED'});
  39. assert.equal((await f.store.list('KfArtifact')).length,0);
  40. });
  41. test('同账号跨课题、会话和档案别名仍27人;报告ID、内容和历史参与键不变',async()=>{
  42. const f=fixture(),binding=await f.confirm(await f.prepare());
  43. const first=await f.start(),second=await f.start(key,'topic-two');
  44. await f.confirm(await f.prepare(otherKey));const third=await f.start(otherKey,'topic-three');
  45. assert.equal(new Set([first.memberKey,second.memberKey,third.memberKey]).size,1);
  46. const saved=await f.store.create('KfArtifact',{externalKey:'workshop:artifact:fixture',meta:{workshop:{visibility:'public'}},recordState:'active',title:'真实报告原题',rawText:'原文',version:1,ownerSession:first.sessionId,uploadedByKey:first.participantKey,ownerName:'果鹏'});
  47. const before=await f.store.get('KfArtifact',saved.objectId),board=await f.call('ksfGetPublicBoard',{},null);
  48. const members=indexBoardMembers({...board,member:WORKSHOP_ROSTER.members});
  49. assert.equal(members.members.length,27);assert.equal(members.members.filter(m=>m.username==='guopeng').length,1);
  50. assert.equal(members.memberId('artifact',board.artifact[0]),binding.memberKey);
  51. assert.deepEqual(await f.store.get('KfArtifact',saved.objectId),before);
  52. assert.equal(board.artifact[0].uploadedByKey,first.participantKey);
  53. assert.ok(!JSON.stringify(board).includes('platformUserId'));assert.ok(!JSON.stringify(board).includes('fixture-room'));
  54. });
  55. test('不存在账号、跨企业、同名不同账号及已绑定改绑均不静默合并',async()=>{
  56. const f=fixture();await assert.rejects(()=>f.prepare(key,'doesnotexist'),{code:'ACCOUNT_NOT_REGISTERED'});
  57. const p=await f.prepare();f.setUser({...audit.results.find(x=>x.username==='guopeng'),companyId:'other-company'});
  58. await assert.rejects(()=>f.confirm(p),{code:'COMPANY_FORBIDDEN'});
  59. f.setUser(audit.results.find(x=>x.username==='liuna'));await assert.rejects(()=>f.confirm(p),{code:'ACCOUNT_BINDING_CONFLICT'});
  60. f.setUser(audit.results.find(x=>x.username==='guopeng'));await f.confirm(p);
  61. await assert.rejects(()=>f.prepare(key,'liuna'),{code:'ACCOUNT_BINDING_CONFLICT'});
  62. const foreign=await f.account({action:'status',participantProfileKey:otherKey});assert.equal(foreign.status,'unbound');
  63. });
  64. test('过期与替换链接失效,绑定重复确认幂等且服务隔离',async()=>{
  65. const f=fixture(),old=await f.prepare();f.advance(1);const next=await f.prepare();
  66. await assert.rejects(()=>f.confirm(old),{code:'ACCOUNT_LINK_EXPIRED'});
  67. const bound=await f.confirm(next);assert.deepEqual(await f.confirm(next),bound);
  68. const status=await f.call('ksfWorkshopAccount',{action:'status',participantProfileKey:key},{...service,id:'another-service'});assert.equal(status.status,'unbound');
  69. const g=fixture(),expired=await g.prepare();g.advance(31*60000);await assert.rejects(()=>g.confirm(expired),{code:'ACCOUNT_LINK_EXPIRED'});
  70. });