workshop-identity.test.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import {registerAgentProfile,readAgentProfileByKey,checkAgentProfile,registerParticipantProfile,registerChannelParticipant,readParticipantProfile,checkParticipantProfile,findParticipantProfiles,listIdentityProfiles,adoptLegacyAgentProfile,bindOwnAgent,bindRegisteredParticipant,redactIdentity,executeIdentity} from '../skills/ksf-workshop-runtime/scripts/ksf-identity.mjs';
  7. import {runWorkshop} from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
  8. import {registerBridgeIdentity} from '../skills/ksf-workshop-runtime/scripts/ksf-identity-bridge.mjs';
  9. const agentInput={name:'新数字人',role:'workshop',identityBasis:'operator_registration',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',tenantId:null,accountId:null,accountName:null,runtimeAgentId:null,wecomAppAgentId:null}};
  10. const employeeInput={name:'新员工',identityBasis:'participant_registration',account:{platform:'wecom',tenantId:null,accountId:null,accountName:null,employeeNumber:null}};
  11. function directory(t){const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-identity-test-'));t.after(()=>{assert.equal(path.dirname(dir),fs.realpathSync(os.tmpdir()));assert.ok(path.basename(dir).startsWith('ksf-identity-test-'));fs.rmSync(dir,{recursive:true});});return dir;}
  12. const dirs=root=>({profileDir:path.join(root,'agents'),participantDir:path.join(root,'participants')});
  13. test('新 Agent 自助登记生成通用稳定 UUID、同资料重用,密钥字段拒绝',t=>{
  14. const root=directory(t),opts=dirs(root),a=registerAgentProfile(agentInput,opts),before=fs.readFileSync(path.join(opts.profileDir,a.profile.key.split(':')[1]+'.json'));
  15. assert.match(a.profile.key,/^agent-registration:/);assert.equal(a.changed,true);assert.equal(registerAgentProfile({...agentInput,reuseKey:a.profile.key},opts).changed,false);assert.equal(registerAgentProfile(agentInput,opts).needsReview,true);assert.equal(readAgentProfileByKey(a.profile.key,opts.profileDir).name,'新数字人');
  16. assert.equal(checkAgentProfile(a.profile.key,{...agentInput,collectedAt:'2026-11-02T02:00:00.000Z'},opts).matches,true);
  17. assert.deepEqual(fs.readFileSync(path.join(opts.profileDir,a.profile.key.split(':')[1]+'.json')),before);
  18. const duplicate=registerAgentProfile({...agentInput,name:'新数字人',account:{...agentInput.account,accountName:'其他账号'}},opts);assert.equal(duplicate.needsReview,true);assert.equal(duplicate.candidates.length,1);
  19. assert.throws(()=>registerAgentProfile({...agentInput,account:{...agentInput.account,token:'fixture'}},opts),{code:'INVALID_IDENTITY_FIELDS'});
  20. const separate=registerAgentProfile({...agentInput,confirmNew:true},opts);assert.equal(separate.changed,true);assert.notEqual(separate.profile.key,a.profile.key);
  21. });
  22. test('真实 Agent ID 生成可校验的账号摘要;稳定 ID 冲突拒绝',t=>{
  23. const opts=dirs(directory(t)),input={...agentInput,identityBasis:'runtime_config',account:{...agentInput.account,accountId:'fixture-agent-account'}};
  24. const p=registerAgentProfile(input,opts).profile;assert.match(p.key,/^agent-account:[a-f0-9]{64}$/);assert.equal(readAgentProfileByKey(p.key,opts.profileDir).key,p.key);
  25. assert.throws(()=>registerAgentProfile({...input,name:'changed'},opts),{code:'AGENT_PROFILE_CONFLICT'});
  26. assert.throws(()=>checkAgentProfile(p.key,{...input,runtimePlatform:'changed'},opts),{code:'AGENT_PROFILE_CONFLICT'});
  27. });
  28. test('可信来源必须有稳定 ID,篡改 UUID 档案的来源不能冒充运行配置',t=>{
  29. const opts=dirs(directory(t));
  30. for(const identityBasis of ['runtime_config','channel_context'])assert.throws(()=>registerAgentProfile({...agentInput,identityBasis},opts),{code:'RUNTIME_AGENT_ID_REQUIRED'});
  31. for(const identityBasis of ['channel_context','existing_record'])assert.throws(()=>registerParticipantProfile({...employeeInput,identityBasis},opts),{code:'STABLE_EMPLOYEE_ID_REQUIRED'});
  32. assert.throws(()=>registerParticipantProfile({...employeeInput,identityBasis:'channel_context',account:{...employeeInput.account,accountId:'user-without-corp'}},opts),{code:'STABLE_EMPLOYEE_ID_REQUIRED'});
  33. const agent=registerAgentProfile(agentInput,opts).profile,file=path.join(opts.profileDir,agent.key.split(':')[1]+'.json'),row=JSON.parse(fs.readFileSync(file,'utf8'));
  34. row.identityBasis='runtime_config';row.account.runtimeAgentId='actual-looking-id';fs.writeFileSync(file,JSON.stringify(row));
  35. assert.throws(()=>readAgentProfileByKey(agent.key,opts.profileDir),{code:'INVALID_IDENTITY_FILE'});
  36. const employee=registerParticipantProfile(employeeInput,opts).profile,employeeFile=path.join(opts.participantDir,employee.key.split(':')[1]+'.json'),person=JSON.parse(fs.readFileSync(employeeFile,'utf8'));
  37. person.identityBasis='channel_context';person.account.tenantId='test-corp';person.account.accountId='test-user';fs.writeFileSync(employeeFile,JSON.stringify(person));
  38. assert.throws(()=>readParticipantProfile(employee.key,opts.participantDir),{code:'INVALID_IDENTITY_FILE'});
  39. });
  40. test('任意数量同角色数字人可共存,旧档案迁入不覆盖原件,CLI仅输出摘要',t=>{
  41. const root=directory(t),opts=dirs(root),first=registerAgentProfile(agentInput,opts).profile;
  42. const second=registerAgentProfile({...agentInput,name:'另一个产品数字人'},opts).profile;assert.notEqual(first.key,second.key);
  43. const legacy=path.join(root,'agent-self-workshop.json'),original=fs.readFileSync(path.join(opts.profileDir,first.key.split(':')[1]+'.json'));fs.writeFileSync(legacy,original,{mode:0o600});
  44. const next={profileDir:path.join(root,'migrated')};assert.equal(adoptLegacyAgentProfile(legacy,next).changed,true);assert.equal(adoptLegacyAgentProfile(legacy,next).changed,false);assert.deepEqual(fs.readFileSync(legacy),original);
  45. const input=path.join(root,'summary.json');fs.writeFileSync(input,JSON.stringify({...employeeInput,account:{...employeeInput.account,accountName:'private-account-name'}}));
  46. const out=executeIdentity(['register-employee','--input',input],opts);assert.equal(out.databaseWrites,0);assert.equal(out.networkRequests,0);assert.equal(out.accountAuthenticated,false);assert.ok(!JSON.stringify(out).includes('private-account-name'));
  47. const status=executeIdentity(['status'],opts);assert.equal(status.agents.length,2);assert.equal(status.participants.length,1);
  48. assert.throws(()=>registerParticipantProfile({...employeeInput,reuseKey:out.profile.key,confirmNew:true},opts),{code:'INVALID_IDENTITY_VALUE'});
  49. assert.throws(()=>readAgentProfileByKey('agent-registration:../../invalid',opts.profileDir),{code:'INVALID_AGENT_KEY'});
  50. });
  51. test('新员工先登记;稳定账号自动复用,无账号同名需人工确认',t=>{
  52. const opts=dirs(directory(t)),first=registerParticipantProfile(employeeInput,opts);assert.equal(first.changed,true);assert.match(first.profile.key,/^participant-registration:/);
  53. const candidates=findParticipantProfiles('新员工',opts);assert.equal(candidates.length,1);assert.ok(!JSON.stringify(candidates).includes('accountId'));
  54. const pending=registerParticipantProfile(employeeInput,opts);assert.equal(pending.needsReview,true);assert.equal(pending.candidates[0].key,first.profile.key);
  55. const reused=registerParticipantProfile({...employeeInput,reuseKey:first.profile.key},opts);assert.equal(reused.changed,false);assert.equal(reused.profile.key,first.profile.key);
  56. const other=registerParticipantProfile({...employeeInput,confirmNew:true},opts);assert.notEqual(other.profile.key,first.profile.key);
  57. const stable={...employeeInput,name:'有账号员工',identityBasis:'channel_context',account:{...employeeInput.account,tenantId:'corp-test',accountId:'wecom-user-7'}};
  58. const a=registerParticipantProfile(stable,opts),b=registerParticipantProfile(stable,opts);assert.equal(a.profile.key,b.profile.key);assert.equal(b.changed,false);
  59. assert.throws(()=>registerParticipantProfile({...stable,name:'不同显示名'},opts),{code:'PARTICIPANT_PROFILE_CONFLICT'});
  60. assert.equal(checkParticipantProfile(first.profile.key,employeeInput,opts).matches,true);
  61. });
  62. test('workshop-start 必须引用登记的员工和 Agent 档案,并注入字段快照',async t=>{
  63. const opts=dirs(directory(t)),agentRow=registerAgentProfile(agentInput,opts).profile,employeeRow=registerParticipantProfile(employeeInput,opts).profile;
  64. const agent=readAgentProfileByKey(agentRow.key,opts.profileDir),participant=readParticipantProfile(employeeRow.key,opts.participantDir),calls=[];
  65. const call=async(name,params)=>{calls.push({name,params});return name==='ksfGetIngestContract'?{workshop:{identitySchema:'ksf.agent-self.v1'}}:{sessionId:'session'};};
  66. await assert.rejects(()=>runWorkshop('workshop-start',{participant:{profileKey:participant.key}},{requestId:'missing-agent',participantProfile:participant,call}),{code:'AGENT_PROFILE_REQUIRED'});
  67. await assert.rejects(()=>runWorkshop('workshop-start',{}, {requestId:'missing-employee',agentProfile:agent,call}),{code:'PARTICIPANT_PROFILE_REQUIRED'});assert.equal(calls.length,0);
  68. const input={workshopKey:'w',participant:{profileKey:participant.key},conversation:{key:'conversation-1',channel:'wecom'},groupId:'',dataMode:'sample'};
  69. await runWorkshop('workshop-start',input,{requestId:'start',agentProfile:agent,participantProfile:participant,call});
  70. const start=calls.find(x=>x.name==='ksfStartWorkshopSession').params;assert.deepEqual(start.agent,bindOwnAgent({},agent).agent);assert.deepEqual(start.participant,bindRegisteredParticipant({profileKey:participant.key},participant));assert.equal(start.dataMode,'sample');
  71. calls.length=0;await assert.rejects(()=>runWorkshop('workshop-start',{...input,agent:{name:'别的数字人'}},{requestId:'fake',agentProfile:agent,participantProfile:participant,call}),{code:'AGENT_IDENTITY_MISMATCH'});assert.equal(calls.length,0);
  72. await assert.rejects(()=>runWorkshop('workshop-start',input,{requestId:'ops',agentProfile:{...agent,role:'operations'},participantProfile:participant,call}),{code:'AGENT_ROLE_MISMATCH'});assert.equal(calls.length,0);
  73. });
  74. test('旧服务不支持身份版本时会话写入前停止',async t=>{
  75. const opts=dirs(directory(t)),agentRow=registerAgentProfile(agentInput,opts).profile,employeeRow=registerParticipantProfile(employeeInput,opts).profile,calls=[];
  76. const agent=readAgentProfileByKey(agentRow.key,opts.profileDir),employee=readParticipantProfile(employeeRow.key,opts.participantDir);
  77. await assert.rejects(()=>runWorkshop('workshop-start',{participant:{profileKey:employee.key}},{requestId:'old-service',agentProfile:agent,participantProfile:employee,call:async name=>{calls.push(name);return{workshop:{version:'old'}};}}),{code:'IDENTITY_CAPABILITY_UNAVAILABLE'});assert.deepEqual(calls,['ksfGetIngestContract']);
  78. await assert.rejects(()=>runWorkshop('workshop-start',{},{requestId:'no-agent',participantProfile:employee,call:async()=>{throw Error('must not call');}}),{code:'AGENT_PROFILE_REQUIRED'});
  79. });
  80. test('只读回读脱敏账号与会话键,公开producer不携带账号值',async t=>{
  81. const opts=dirs(directory(t)),agentRow=registerAgentProfile({...agentInput,account:{...agentInput.account,accountId:'private-account'}},opts).profile,agent=readAgentProfileByKey(agentRow.key,opts.profileDir);
  82. const result=await runWorkshop('workshop-read',{className:'KfArtifact',objectId:'artifact'},{requestId:'read',call:async(n,params)=>{assert.equal(n,'ksfGetRecord');assert.equal(params.objectId,'artifact');return{record:{title:'报告',agentName:agent.name,userRef:{objectId:'private-user'},meta:{ownerId:'private-service',attribution:{agentName:agent.name,agentAccount:{accountId:'private-account'},participantAccount:{accountId:'private-person'},conversationKey:'private-chat'}}}};}});
  83. assert.equal(result.record.title,'报告');assert.ok(!JSON.stringify(result).includes('private-'));assert.deepEqual(redactIdentity({producer:{agent:{name:agent.name,key:agent.key,identityBasis:agent.identityBasis,account:{accountId:'private-account'}}}}).producer.agent,{name:agent.name,key:agent.key,identityBasis:agent.identityBasis});
  84. assert.equal(listIdentityProfiles(opts).agents.length,1);
  85. });
  86. const channelContext={version:'ksf.wecom-aibot-context.v1',botId:'private-bot-one',senderId:'private-sender-one',chatType:'single',chatId:null,messageId:'message-one'};
  87. function channelAgent(opts,botId=channelContext.botId){const a=registerAgentProfile({...agentInput,name:botId==='private-bot-one'?'王刚001':'运维001',identityBasis:'runtime_config',account:{platform:'wecom',accountId:botId}},opts).profile;return readAgentProfileByKey(a.key,opts.profileDir);}
  88. test('aibot 无企业ID或姓名也能按发送者自动登记,单聊换群不改变员工键',t=>{
  89. const opts=dirs(directory(t)),agentProfile=channelAgent(opts);
  90. const first=registerChannelParticipant(channelContext,{...opts,agentProfile});assert.equal(first.changed,true);assert.equal(first.needsNameBinding,true);assert.match(first.profile.name,/^员工待绑定·/);
  91. const row=readParticipantProfile(first.profile.key,opts.participantDir);assert.equal(row.account.tenantId,null);assert.equal(row.userId,null);assert.equal(row.account.accountId,channelContext.senderId);assert.equal(row.account.scopeId,channelContext.botId);assert.equal(row.account.identifierType,'wecom_aibot_sender');
  92. const filename=path.join(opts.participantDir,row.key.split(':')[1]+'.json'),before=fs.readFileSync(filename);
  93. const again=registerChannelParticipant({...channelContext,chatType:'group',chatId:'private-group',messageId:'message-two'},{...opts,agentProfile});assert.equal(again.changed,false);assert.equal(again.profile.key,first.profile.key);assert.deepEqual(fs.readFileSync(filename),before);
  94. assert.equal(first.channelContextOriginVerified,false);assert.ok(!JSON.stringify(first).includes('private-'));
  95. });
  96. test('同一群里不同发送者分开;跨机器人标识不擅自合并',t=>{
  97. const opts=dirs(directory(t)),agentProfile=channelAgent(opts),a=registerChannelParticipant({...channelContext,chatType:'group',chatId:'private-group'},{...opts,agentProfile});
  98. const b=registerChannelParticipant({...channelContext,senderId:'private-sender-two',chatType:'group',chatId:'private-group'},{...opts,agentProfile});assert.notEqual(a.profile.key,b.profile.key);
  99. const other=channelAgent(opts,'private-bot-two'),c=registerChannelParticipant({...channelContext,botId:'private-bot-two'},{...opts,agentProfile:other});assert.notEqual(a.profile.key,c.profile.key);assert.equal(listIdentityProfiles(opts).participants.length,3);
  100. });
  101. test('aibot 缺发送者/机器人/群会话ID或机器人不匹配在登记前停止',t=>{
  102. const opts=dirs(directory(t)),agentProfile=channelAgent(opts);
  103. for(const bad of [{...channelContext,senderId:''},{...channelContext,botId:''},{...channelContext,chatType:'group',chatId:null},{...channelContext,displayName:'猜出的姓名'},{...channelContext,rawText:'不得从正文提取身份'}])assert.throws(()=>registerChannelParticipant(bad,{...opts,agentProfile}));
  104. assert.throws(()=>registerChannelParticipant({...channelContext,botId:'different-bot'},{...opts,agentProfile}),{code:'CHANNEL_AGENT_MISMATCH'});
  105. assert.equal(listIdentityProfiles(opts).participants.length,0);
  106. });
  107. test('aibot 旧文件 CLI 已关闭;旧合同与错机器人阻止会话',async t=>{
  108. const root=directory(t),opts=dirs(root),agentProfile=channelAgent(opts),file=path.join(root,'inbound.json');fs.writeFileSync(file,JSON.stringify(channelContext),{mode:0o600});
  109. for(const argv of [[],['--context',file],['--agent-profile',agentProfile.key,'--context',file],['--message-id','made-up']])assert.throws(()=>executeIdentity(['register-channel-employee',...argv],opts),{code:'TRUSTED_IDENTITY_TOOL_REQUIRED'});
  110. assert.equal(listIdentityProfiles(opts).participants.length,0);
  111. const result=registerBridgeIdentity({version:'ksf.current-employee-registration.v1',agentProfileKey:agentProfile.key,identity:channelContext},opts);assert.ok(!JSON.stringify(result).includes('private-'));assert.equal(result.databaseWrites,0);assert.equal(result.accountAuthenticated,false);
  112. const participantProfile=readParticipantProfile(result.profile.key,opts.participantDir),task={participant:{profileKey:participantProfile.key},conversation:{key:'private-chat',channel:'wecom'}},calls=[];
  113. const options={requestId:'channel-start',agentProfile,participantProfile,call:async name=>{calls.push(name);return{workshop:{identitySchema:'ksf.agent-self.v1'}};}};
  114. await assert.rejects(()=>runWorkshop('workshop-start',task,options),{code:'CHANNEL_IDENTITY_CAPABILITY_UNAVAILABLE'});assert.deepEqual(calls,['ksfGetIngestContract']);
  115. const call=async name=>{calls.push(name);return{workshop:{identitySchema:'ksf.agent-self.v1',channelIdentitySchema:'ksf.wecom-aibot-sender.v1'}};};
  116. calls.length=0;await assert.rejects(()=>runWorkshop('workshop-start',task,{...options,call,agentProfile:channelAgent(opts,'private-bot-two')}),{code:'CHANNEL_AGENT_MISMATCH'});assert.deepEqual(calls,['ksfGetIngestContract']);
  117. assert.deepEqual(redactIdentity({scopeId:'private-bot',botId:'private-bot',senderId:'private-user',name:'显示标签'}),{name:'显示标签'});
  118. });
  119. test('通用员工 JSON 不能替代通道工具;内部登记复用规范键且拒绝错实例与运维角色',t=>{
  120. const root=directory(t),opts=dirs(root),agentProfile=channelAgent(opts),input=path.join(root,'input.json');
  121. for(const value of [
  122. {...employeeInput,identityBasis:'channel_context',account:{platform:'wecom',tenantId:'corp',accountId:'sender'}},
  123. {...employeeInput,account:{platform:'wecom',identifierType:'wecom_aibot_sender',scopeId:'bot',accountId:'sender'}}
  124. ]){fs.writeFileSync(input,JSON.stringify(value));assert.throws(()=>executeIdentity(['register-employee','--input',input],opts),{code:'TRUSTED_IDENTITY_TOOL_REQUIRED'});}
  125. assert.equal(listIdentityProfiles(opts).participants.length,0);
  126. const request={version:'ksf.current-employee-registration.v1',agentProfileKey:agentProfile.key,identity:channelContext};
  127. const a=registerBridgeIdentity(request,opts),b=registerBridgeIdentity({...request,identity:{...channelContext,messageId:'another-message',chatType:'group',chatId:'group'}},opts);
  128. assert.equal(a.profile.key,b.profile.key);assert.equal(a.changed,true);assert.equal(b.changed,false);
  129. assert.throws(()=>registerBridgeIdentity({...request,messageId:'extra-field'},opts),{code:'INVALID_BRIDGE_REQUEST'});
  130. assert.throws(()=>registerBridgeIdentity({...request,identity:{...channelContext,botId:'other-bot'}},opts),{code:'CHANNEL_AGENT_MISMATCH'});
  131. const ops=registerAgentProfile({...agentInput,name:'运维001',role:'operations',account:{platform:'wecom',accountId:'operations-bot'}},opts).profile;
  132. assert.throws(()=>registerBridgeIdentity({...request,agentProfileKey:ops.key},opts),{code:'AGENT_ROLE_MISMATCH'});
  133. assert.equal(listIdentityProfiles(opts).participants.length,1);
  134. });
  135. test('上传实名与待同步回执均保留;成功保存不因实名状态再次调用',async t=>{
  136. const opts=dirs(directory(t)),agentProfile=channelAgent(opts);
  137. const verified={employee:{key:'employee:'+'a'.repeat(64),name:'测试员工',departmentNames:['测试产品部'],source:'wecom_directory',nameVerified:true,verifiedAt:'2026-11-03T00:00:00.000Z'},employeeIdentity:{automatic:true,status:'verified'}};
  138. const pending={employee:{key:'channel-employee:'+'b'.repeat(64),name:null,source:'wecom_sender',nameVerified:false},employeeIdentity:{automatic:true,status:'pending',reason:'WECOM_DIRECTORY_NOT_CONFIGURED'}};
  139. for(const receipt of [verified,pending,{}]){
  140. const calls=[];
  141. const result=await runWorkshop('workshop-save',{sessionId:'original-session',taskKey:'original-task',version:1},{requestId:'original-save',agentProfile,call:async(name,params)=>{
  142. calls.push({name,params});
  143. if(name==='ksfGetRecord')return{record:{meta:{attribution:{agentKey:agentProfile.key,agentName:agentProfile.name,agentRuntimePlatform:agentProfile.runtimePlatform,agentChannel:agentProfile.channel}}}};
  144. assert.equal(name,'ksfSaveWorkshopTask');assert.equal(params.requestId,'original-save');
  145. assert.equal(params.sessionId,'original-session');assert.equal(params.taskKey,'original-task');
  146. return{artifactId:'saved-artifact',...receipt,meta:{attribution:{participantAccount:{accountId:'private-sender'},agentAccount:{accountId:'private-bot'}}}};
  147. }});
  148. assert.equal(result.artifactId,'saved-artifact');assert.deepEqual(result.employee,receipt.employee);assert.deepEqual(result.employeeIdentity,receipt.employeeIdentity);
  149. assert.deepEqual(calls.map(x=>x.name),['ksfGetRecord','ksfSaveWorkshopTask']);assert.ok(!JSON.stringify(result).includes('private-'));
  150. }
  151. });
  152. test('通讯录查询失败透传并停止,Skill运行库不换键重试保存',async t=>{
  153. const opts=dirs(directory(t)),agentProfile=channelAgent(opts),calls=[];
  154. await assert.rejects(()=>runWorkshop('workshop-save',{sessionId:'original-session',taskKey:'original-task'},{requestId:'original-save',agentProfile,call:async(name,params)=>{
  155. calls.push({name,params});
  156. if(name==='ksfGetRecord')return{record:{meta:{attribution:{agentKey:agentProfile.key,agentName:agentProfile.name,agentRuntimePlatform:agentProfile.runtimePlatform,agentChannel:agentProfile.channel}}}};
  157. throw Object.assign(Error('fixture failure'),{code:'WECOM_SENDER_MAPPING_REQUIRED'});
  158. }}),{code:'WECOM_SENDER_MAPPING_REQUIRED'});
  159. assert.deepEqual(calls.map(x=>x.name),['ksfGetRecord','ksfSaveWorkshopTask']);assert.equal(calls[1].params.requestId,'original-save');
  160. });