employee-read.test.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  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 os from 'node:os';
  6. import path from 'node:path';
  7. import { attachWecomEmployeeReadHandler } from '../server/identity/employee-read.mjs';
  8. import { buildEmployeeRead } from '../server/identity/build-employee-read.mjs';
  9. import { manage } from '../server/identity/manage.mjs';
  10. import { runWorkshop, createWorkshopCaller } from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
  11. import { parseArgs } from '../skills/ksf-workshop-runtime/scripts/ksf-secure.mjs';
  12. const hash = v => crypto.createHash('sha256').update(v).digest('hex');
  13. const canonical = v => v && typeof v === 'object' ? Array.isArray(v) ? '[' + v.map(canonical).join(',') + ']' : '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}' : JSON.stringify(v);
  14. const account = { platform: 'wecom', identifierType: 'wecom_aibot_sender', scopeId: 'private-bot', accountId: 'private-sender' };
  15. const participant = { key: 'participant-account:' + 'a'.repeat(64), name: '员工待绑定', identityBasis: 'channel_context', account };
  16. const agent = { key: 'agent-account:' + 'b'.repeat(64), name: '测试数字人', role: 'workshop', runtimePlatform: 'hermes', channel: 'wecom', account: { platform: 'wecom', accountId: account.scopeId } };
  17. const params = { sessionId: 'session1', participantProfileKey: participant.key, agentProfileKey: agent.key, requestId: 'read1' };
  18. function fixture({ configured = true, apiError = false } = {}) {
  19. const calls = [], writes = [];
  20. const session = { objectId: 'session1', workshopKey: 'test-workshop', meta: { ownerId: 'service1', attribution: {
  21. participantKey: 'participant:' + hash(canonical(['test-workshop', participant.key])), participantAccount: account, identityBasis: 'channel_context',
  22. agentKey: agent.key, agentName: agent.name, agentRuntimePlatform: agent.runtimePlatform, agentChannel: agent.channel, agentAccount: agent.account,
  23. } } };
  24. const fail = code => { throw Object.assign(Error(code), { code }); };
  25. const app = { handlers: {}, hash, canonical, fail, need: (actor, scope) => { if (!actor) fail('AUTH_REQUIRED'); if (!actor.scopes.some(s => s === scope || s === '*')) fail('FORBIDDEN'); }, operation: () => { writes.push('operation'); throw Error('must not write'); } };
  26. const config = { enabled: true, corpId: 'private-corp', secret: 'fixture-secret', bots: { 'private-bot': { mappingVerified: true, senderIdType: 'mapped', userIds: { 'private-sender': 'private-user' } } } };
  27. const fetchImpl = async url => {
  28. calls.push(url);
  29. const body = apiError ? { errcode: 48002, errmsg: 'fixture-secret must not appear in error' } : new URL(url).pathname.endsWith('/gettoken')
  30. ? { errcode: 0, access_token: 'fixture-access-token', expires_in: 7200 }
  31. : { errcode: 0, userid: 'private-user', status: 1, name: '测试员工甲', department: [], mobile: 'private-mobile' };
  32. return { ok: true, json: async () => body };
  33. };
  34. const store = { get: async (kind, id) => kind === 'KfSession' && id === 'session1' ? structuredClone(session) : null,
  35. update: () => { writes.push('update'); throw Error('must not write'); } };
  36. const settings = configured ? { wecomDirectory: config } : {};
  37. attachWecomEmployeeReadHandler(app, { store, settings, fetchImpl });
  38. return { app, session, settings, store, calls, writes, actor: { id: 'service1', scopes: ['data:read'] } };
  39. }
  40. test('employee-read从Skill到服务端实际查询姓名,沿用data:read且服务端零写入', async () => {
  41. const f = fixture(), calls = [];
  42. const result = await runWorkshop('employee-read', { sessionId: 'session1', participant: { profileKey: participant.key } }, {
  43. requestId: 'minimal-read', agentProfile: agent, participantProfile: participant,
  44. call: async (name, p) => { calls.push({ name, p }); return name === 'ksfGetRecord' ? { record: structuredClone(f.session) } : f.app.handlers[name](p, f.actor); },
  45. });
  46. assert.deepEqual(calls.map(c => c.name), ['ksfGetRecord', 'ksfReadWecomEmployee']);
  47. assert.equal(calls[1].p.requestId, 'minimal-read');
  48. assert.equal(result.employee.name, '测试员工甲'); assert.equal(result.employee.nameVerified, true);
  49. assert.equal(result.readOnly, true); assert.equal(result.databaseWrites, 0); assert.equal(result.employeeIdentity.persisted, false);
  50. assert.equal(f.calls.length, 2); assert.deepEqual(f.writes, []);
  51. for (const secret of ['fixture-secret', 'fixture-access-token', 'private-user', 'private-mobile', 'private-bot', 'private-sender']) assert.ok(!JSON.stringify(result).includes(secret));
  52. });
  53. test('匿名、其他服务、其他员工、其他Agent或无读取权限在通讯录请求前被拒绝', async () => {
  54. for (const [input, actor, code] of [
  55. [params, null, 'AUTH_REQUIRED'], [params, { id: 'service1', scopes: ['ingest:write'] }, 'FORBIDDEN'],
  56. [params, { id: 'another', scopes: ['data:read'] }, 'FORBIDDEN'],
  57. [{ ...params, participantProfileKey: 'participant-account:' + 'c'.repeat(64) }, null, 'IDENTITY_CONFLICT'],
  58. [{ ...params, agentProfileKey: 'agent-account:' + 'c'.repeat(64) }, null, 'IDENTITY_CONFLICT'],
  59. [{ ...params, apply: true }, null, 'VALIDATION_FAILED'],
  60. ]) {
  61. const f = fixture();
  62. const effectiveActor = actor || (code === 'AUTH_REQUIRED' ? null : f.actor);
  63. await assert.rejects(() => f.app.handlers.ksfReadWecomEmployee(input, effectiveActor), { code });
  64. assert.equal(f.calls.length, 0); assert.deepEqual(f.writes, []);
  65. }
  66. });
  67. test('缺配置、缺映射和无通讯录权限给出真实错误,不退回看板占位名', async () => {
  68. const absent = fixture({ configured: false });
  69. await assert.rejects(() => absent.app.handlers.ksfReadWecomEmployee(params, absent.actor), { code: 'WECOM_DIRECTORY_NOT_CONFIGURED' });
  70. assert.equal(absent.calls.length, 0);
  71. const mapping = fixture(); mapping.settings.wecomDirectory.bots['private-bot'].userIds = {};
  72. await assert.rejects(() => mapping.app.handlers.ksfReadWecomEmployee(params, mapping.actor), { code: 'WECOM_SENDER_MAPPING_REQUIRED' });
  73. assert.equal(mapping.calls.length, 0);
  74. const denied = fixture({ apiError: true });
  75. await assert.rejects(() => denied.app.handlers.ksfReadWecomEmployee(params, denied.actor), e => e.code === 'WECOM_DIRECTORY_PERMISSION_REQUIRED' && !e.message.includes('fixture-secret'));
  76. assert.deepEqual(denied.writes, []);
  77. });
  78. test('CLI白名单接受employee-read但仍要求Agent,错员工在远端姓名查询前停止', async () => {
  79. const input = path.resolve('input.json');
  80. assert.equal(parseArgs(['employee-read', '--input', input, '--request-id', 'r1', '--agent-profile', agent.key]).command, 'employee-read');
  81. assert.throws(() => parseArgs(['employee-read', '--input', input, '--request-id', 'r1']), { code: 'AGENT_PROFILE_REQUIRED' });
  82. const f = fixture(), calls = [];
  83. await assert.rejects(() => runWorkshop('employee-read', { sessionId: 'session1', participant: { profileKey: participant.key } }, {
  84. requestId: 'r1', agentProfile: agent, participantProfile: participant, call: async name => { calls.push(name); return { record: { ...f.session, meta: { attribution: { ...f.session.meta.attribution, participantKey: 'another-participant' } } } }; },
  85. }), { code: 'PARTICIPANT_SESSION_MISMATCH' });
  86. assert.deepEqual(calls, ['ksfGetRecord']);
  87. });
  88. test('受权网络调用保留远端函数及真实错误码且不自动重试', async () => {
  89. const calls = [];
  90. const caller = createWorkshopCaller({ token: 'fixture-token', fetchImpl: async (url, options) => {
  91. calls.push({ url, options });
  92. return { ok: true, json: async () => ({ result: { ok: false, errors: [{ code: 'WECOM_DIRECTORY_NOT_CONFIGURED', message: 'private-secret' }] } }) };
  93. } });
  94. await assert.rejects(() => caller('ksfReadWecomEmployee', params), e => e.code === 'WECOM_DIRECTORY_NOT_CONFIGURED' && e.remoteFunction === 'ksfReadWecomEmployee' && e.remoteResponseReceived === true && !e.message.includes('private-secret'));
  95. assert.equal(calls.length, 1); assert.equal(JSON.parse(calls[0].options.body).path, '/ksfReadWecomEmployee');
  96. });
  97. test('单函数补丁只新增读取函数,部署可重复、可回退且保留r5四函数', async t => {
  98. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-employee-read-'));
  99. t.after(() => fs.rmSync(dir, { recursive: true }));
  100. const base = { name: 'ksfGetPublicBoard', objectId: 'public1', path: '/ksfGetPublicBoard', ACL: {},
  101. code: 'function bootstrap(){attachWecomEmployeeHandlers(app,{store,settings});}\nfunction handler(request,response){return runDatabaseFunction("ksfGetPublicBoard",request,response,{});}' };
  102. const built = buildEmployeeRead(base);
  103. new Function(built.functions[0].code);
  104. for (const entry of built.functions) fs.writeFileSync(path.join(dir, entry.name + '.js'), entry.code);
  105. fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ ...built, functions: built.functions.map(({ code, ...meta }) => meta) }));
  106. const rows = [structuredClone(base), ...['ksfStartWorkshopSession', 'ksfSaveWorkshopTask', 'ksfSyncWecomEmployee'].map(name => ({ name, objectId: name, code: 'preserved', ACL: {} }))];
  107. const before = structuredClone(rows), writes = [];
  108. const request = async (method, route, body) => {
  109. if (route.includes('?')) return { results: structuredClone(rows.filter(r => r.name === JSON.parse(new URL('https://test' + route).searchParams.get('where')).name)) };
  110. const id = route.split('/').at(-1);
  111. if (method === 'GET') return structuredClone(rows.find(r => r.objectId === id));
  112. writes.push({ method, route, body });
  113. if (method === 'POST') { rows.push({ objectId: 'read1', ...body }); return { objectId: 'read1' }; }
  114. if (method === 'DELETE') { rows.splice(rows.findIndex(r => r.objectId === id), 1); return {}; }
  115. throw Error('must not edit existing functions');
  116. };
  117. const run = args => manage({ args, request, releaseDir: dir });
  118. const dry = await run(['deploy-employee-read']); assert.equal(dry.readOnly, true); assert.equal(writes.length, 0);
  119. const applied = await run(['deploy-employee-read', '--apply', '--receipt-dir', dir]);
  120. assert.equal(applied.functions, 1); assert.equal(writes.length, 1); assert.equal(rows.length, 5);
  121. assert.deepEqual(rows.slice(0, 4), before);
  122. assert.equal((await run(['deploy-employee-read', '--apply', '--receipt-dir', dir])).unchanged, true); assert.equal(writes.length, 1);
  123. await run(['rollback', '--receipt', applied.receipt, '--apply']); assert.deepEqual(rows, before);
  124. assert.throws(() => buildEmployeeRead({ ...base, code: base.code.replace('attachWecomEmployeeHandlers', 'changed') }), /UNEXPECTED_R5_BASE/);
  125. rows[0].code += '// concurrent edit';
  126. await assert.rejects(() => run(['deploy-employee-read']), { code: 'FUNCTION_BASELINE_MISMATCH' });
  127. });