account-binding.test.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  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',identity={})=>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,...identity}},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. });
  71. test('首次登记缺企业和平台编号时,从本人已确认别名补齐会话,不换员工键',async()=>{
  72. const f=fixture(),profile='participant-registration:098130da-48d3-4d1d-8a2e-a547a6693acb';
  73. await assert.rejects(()=>f.start(profile,'first-use',{tenantId:null,accountId:null}),{code:'ACCOUNT_BINDING_REQUIRED'});
  74. const bound=await f.confirm(await f.prepare(profile));
  75. const first=await f.start(profile,'first-use',{tenantId:null,accountId:null});
  76. const again=await f.start(profile,'first-use',{tenantId:null,accountId:null});
  77. assert.equal(again.sessionId,first.sessionId);assert.equal(first.memberKey,bound.memberKey);
  78. const session=await f.store.get('KfSession',first.sessionId),a=session.meta.attribution;
  79. assert.equal(a.accountProfileKey,profile);assert.equal(a.participantAccount.tenantId,'FKieRbIoGt');
  80. assert.equal(a.participantAccount.accountId,audit.results.find(x=>x.username==='guopeng').userId);
  81. assert.equal(a.participantAccount.accountName,'guopeng');
  82. const board=await f.call('ksfGetPublicBoard',{},null);
  83. assert.equal(board.session[0].memberKey,bound.memberKey);
  84. assert.equal((await f.store.list('KfSession')).length,1);assert.equal((await f.store.list('KfArtifact')).length,0);
  85. });
  86. test('已绑定别名仍拒绝显式跨企业、其他账号和其他平台编号,不回填冲突值',async()=>{
  87. const f=fixture();await f.confirm(await f.prepare());
  88. for(const identity of [{tenantId:'other-company',accountId:null},{tenantId:null,accountName:'liuna',accountId:null},{tenantId:null,accountId:'other-user'}]){
  89. await assert.rejects(()=>f.start(key,'conflict',identity),{code:'ACCOUNT_BINDING_CONFLICT'});
  90. }
  91. assert.equal((await f.store.list('KfSession')).length,0);
  92. assert.equal((await f.account({action:'status',participantProfileKey:key})).username,'guopeng');
  93. });
  94. test('既有企业明确但平台编号为空的合法会话保持原归属字节,可重复复用',async()=>{
  95. const f=fixture();await f.confirm(await f.prepare());
  96. const first=await f.start(key,'legacy-account',{accountId:null});
  97. const before=await f.store.get('KfSession',first.sessionId);
  98. assert.equal(before.meta.attribution.participantAccount.accountId,null);
  99. const again=await f.start(key,'legacy-account',{accountId:null});
  100. assert.equal(again.sessionId,first.sessionId);assert.equal(again.reused,true);
  101. assert.deepEqual(await f.store.get('KfSession',first.sessionId),before);
  102. });
  103. test('短确认入口不携带身份载荷;丢失、篡改引用及伪造服务确认不能绑定',async()=>{
  104. const f=fixture(),prepared=await f.prepare(),url=new URL(prepared.confirmationUrl),reference=new URLSearchParams(url.hash.slice(1)).get('ticket');
  105. assert.match(reference,/^ksfl_[A-Za-z0-9_-]{22}$/);
  106. assert.ok(prepared.confirmationUrl.length<110);
  107. assert.doesNotMatch(prepared.confirmationUrl,/guopeng|participant-|trusted-service|eyJ|\.\.\./);
  108. const actor=await f.app.authenticateWorkshopPlatform({'X-Fmode-Session-Token':'fixture-session'});
  109. const forged='ksfl_'+(reference[5]==='A'?'B':'A')+reference.slice(6);
  110. await assert.rejects(()=>f.call('ksfWorkshopAccount',{action:'confirm',ticket:forged},actor),{code:'ACCOUNT_LINK_EXPIRED'});
  111. await assert.rejects(()=>f.call('ksfWorkshopAccount',{action:'confirm',ticket:reference}),{code:'AUTH_REQUIRED'});
  112. assert.equal((await f.account({action:'status',participantProfileKey:key})).status,'unbound');
  113. assert.equal((await f.store.list('KfSession')).length,0);assert.equal((await f.store.list('KfArtifact')).length,0);
  114. assert.equal((await f.confirm(prepared)).status,'bound');
  115. });
  116. test('既有完整签名链接继续核验;服务端短引用的签名载荷被改动时拒绝确认',async()=>{
  117. const f=fixture(),prepared=await f.prepare();
  118. const links=(await f.store.list('KsfRuntimeConfig')).filter(row=>row.key.startsWith('workshop-account-link:'));
  119. assert.equal(links.length,1);
  120. const signed=links[0].value.ticket;
  121. assert.equal((await f.confirm({confirmationUrl:'https://kangshifu.fmode.cn/api/functions?path=/ksfWorkshopAccount#ticket='+encodeURIComponent(signed)})).status,'bound');
  122. const g=fixture(),next=await g.prepare(),row=(await g.store.list('KsfRuntimeConfig')).find(row=>row.key.startsWith('workshop-account-link:'));
  123. const [body,signature]=row.value.ticket.split('.'),payload=JSON.parse(Buffer.from(body,'base64url').toString('utf8'));
  124. payload.username='liuna';
  125. await g.store.update('KsfRuntimeConfig',row.objectId,{value:{...row.value,ticket:Buffer.from(JSON.stringify(payload)).toString('base64url')+'.'+signature}});
  126. await assert.rejects(()=>g.confirm(next),{code:'REPORT_NOT_CONFIRMED'});
  127. assert.equal((await g.account({action:'status',participantProfileKey:key})).status,'unbound');
  128. });