wecom-directory.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import crypto from 'node:crypto';
  4. import { createWecomDirectory } from '../server/identity/wecom-directory.mjs';
  5. import { attachWecomEmployeeHandlers } from '../server/identity/employee-handlers.mjs';
  6. import { buildModel } from '../src/core/kfs/build.js';
  7. import { employeeCaption } from '../src/core/kfs/employee.js';
  8. const canonical = (v) => JSON.stringify(v);
  9. const hash = (v) => crypto.createHash('sha256').update(v).digest('hex');
  10. const account = (bot = 'test-bot') => ({ platform: 'wecom', identifierType: 'wecom_aibot_sender', scopeId: bot, accountId: 'opaque-test-sender' });
  11. const config = () => ({ enabled: true, corpId: 'test-corp', secret: 'fixture-only-secret', organizationName: '测试企业', bots: { 'test-bot': { mappingVerified: true, senderIdType: 'mapped', userIds: { 'opaque-test-sender': 'directory-user-1' } } } });
  12. const blank = () => Object.fromEntries(['session', 'artifact', 'project', 'opportunity', 'insight', 'source'].map((k) => [k, []]));
  13. function fixtureApi({ user = {}, departmentError = false, tokenError = false } = {}) {
  14. const calls = [];
  15. const fetchImpl = async (url, options) => {
  16. calls.push({ url, options }); const path = new URL(url).pathname;
  17. const body = path.endsWith('/gettoken') ? tokenError ? { errcode: 40001, errmsg: 'contains fixture-only-secret' } : { errcode: 0, access_token: 'fixture-access-token', expires_in: 7200 }
  18. : path.endsWith('/user/get') ? { errcode: 0, userid: 'directory-user-1', name: '测试成员甲', status: 1, alias: '测试昵称', position: '产品经理', department: [3], mobile: 'must-not-be-public', email: 'must-not-be-public', ...user }
  19. : departmentError ? { errcode: 48002 } : { errcode: 0, department: [{ id: 3, name: '测试产品部' }] };
  20. return { ok: true, json: async () => body };
  21. };
  22. return { fetchImpl, calls };
  23. }
  24. function directory(api = fixtureApi(), value = config()) {
  25. return { ...api, resolver: createWecomDirectory({ config: value, hash, canonical, fetchImpl: api.fetchImpl, now: () => Date.parse('2026-11-03T00:00:00Z') }) };
  26. }
  27. test('aibot sender requires verified scope and explicit UserID mapping before any network request', async () => {
  28. for (const cfg of [null, {}, { ...config(), bots: {} }, { ...config(), bots: { 'test-bot': { senderIdType: 'directory_userid' } } }]) {
  29. const f = directory(fixtureApi(), cfg);
  30. await assert.rejects(() => f.resolver.resolve(account()), /WECOM_(?:DIRECTORY_NOT_CONFIGURED|SENDER_MAPPING_REQUIRED)/);
  31. assert.equal(f.calls.length, 0);
  32. }
  33. });
  34. test('directory supplies name and available business fields, without returning credentials or contact identifiers', async () => {
  35. const f = directory(), p = await f.resolver.resolve(account());
  36. assert.equal(p.name, '测试成员甲'); assert.equal(p.displayName, '测试昵称');
  37. assert.deepEqual(p.departmentNames, ['测试产品部']); assert.equal(p.position, '产品经理');
  38. assert.equal(p.nameVerified, true); assert.equal(p.source, 'wecom_directory');
  39. for (const secret of ['fixture-only-secret', 'fixture-access-token', 'directory-user-1', 'must-not-be-public']) assert.ok(!JSON.stringify(p).includes(secret));
  40. await f.resolver.resolve(account()); assert.equal(f.calls.length, 3, 'one member lookup is reused within a sync');
  41. assert.ok(f.calls.every((c) => c.options.redirect === 'error'));
  42. });
  43. test('optional department permission does not discard a verified name; inactive or mismatched members are rejected', async () => {
  44. const f = directory(fixtureApi({ departmentError: true }));
  45. assert.equal((await f.resolver.resolve(account())).name, '测试成员甲');
  46. for (const user of [{ userid: 'another-user' }, { status: 2 }, { name: '' }]) await assert.rejects(() => directory(fixtureApi({ user })).resolver.resolve(account()), { code: 'WECOM_DIRECTORY_MEMBER_UNAVAILABLE' });
  47. await assert.rejects(() => directory(fixtureApi({ tokenError: true })).resolver.resolve(account()), (e) => e.code === 'WECOM_DIRECTORY_API_ERROR' && !e.message.includes('fixture-only-secret'));
  48. });
  49. async function appFixture() {
  50. const api = fixtureApi(), settings = { wecomDirectory: config(), unrelatedSetting: 'preserved' };
  51. const resolver = directory(api, settings.wecomDirectory).resolver;
  52. const profile = await resolver.resolve(account());
  53. settings.wecomEmployees = { [resolver.accountKey(account())]: profile };
  54. const sessions = [
  55. { objectId: 'session1', studentName: '历史占位名', workshopKey: 'workshop1', meta: { ownerId: 'service1', attribution: { participantAccount: account() } } },
  56. { objectId: 'session2', studentName: '历史占位名', workshopKey: 'workshop2', meta: { ownerId: 'service1', attribution: { participantAccount: account() } } },
  57. ];
  58. const raw = { ...blank(), session: sessions.map((s, i) => ({ objectId: s.objectId, studentName: s.studentName, meta: { participantKey: 'workshop-participant-' + i } })),
  59. artifact: [{ objectId: 'a', ownerName: '历史占位名', ownerSession: 'session1', rawText: '正文不能修改' }], project: [{ objectId: 'p', ownerName: '历史占位名', ownerSession: 'session2' }] };
  60. const writes = [], audits = [], business = [];
  61. const runtime = { objectId: 'runtime-id', key: 'runtime', value: structuredClone(settings) };
  62. const store = { get: async (kind, id) => structuredClone(sessions.find((s) => s.objectId === id)), list: async () => [structuredClone(runtime)], lock: async () => {}, update: async (kind, id, patch) => { writes.push({ kind, id, patch }); Object.assign(runtime, patch); } };
  63. const fail = (code) => { throw Object.assign(new Error(code), { code }); };
  64. const app = { handlers: { ksfGetPublicBoard: async () => structuredClone(raw),
  65. ksfStartWorkshopSession: async (p) => { business.push({ name: 'start', p }); return { sessionId: 'new-session' }; },
  66. ksfSaveWorkshopTask: async (p) => { business.push({ name: 'save', p }); return { artifactId: 'new-artifact' }; } }, fail, hash, canonical,
  67. need: (actor, scope) => { if (!actor) fail('AUTH_REQUIRED'); if (!actor.scopes.some((s) => s === '*' || s === scope)) fail('FORBIDDEN'); },
  68. operation: async (actor, name, p, fn) => { audits.push({ name, p }); return fn(store); } };
  69. attachWecomEmployeeHandlers(app, { store, settings, fetchImpl: api.fetchImpl });
  70. return { app, raw, sessions, writes, audits, business, settings, api, runtime, actor: { id: 'admin', scopes: ['*'] } };
  71. }
  72. test('public overlay supplies directory names and stable identity across workshops while preserving history', async () => {
  73. const f = await appFixture(), before = structuredClone(f.raw), result = await f.app.handlers.ksfGetPublicBoard({});
  74. assert.equal(result.artifact[0].ownerName, '测试成员甲'); assert.equal(result.project[0].ownerName, '测试成员甲');
  75. assert.equal(result.session[0].employee.key, result.session[1].employee.key);
  76. const db = buildModel(result); assert.equal(db.members.length, 1); assert.equal(db.members[0].name, '测试成员甲');
  77. assert.equal(db.members[0].artifactIds.length, 1); assert.equal(db.members[0].projectIds.length, 1);
  78. assert.ok(employeeCaption(db.members[0].employee).includes('测试产品部')); assert.ok(employeeCaption(db.members[0].employee).includes('产品经理'));
  79. assert.deepEqual(f.raw, before); assert.equal(f.writes.length, 0);
  80. for (const value of ['fixture-only-secret', 'opaque-test-sender', 'directory-user-1', 'must-not-be-public']) assert.ok(!JSON.stringify(result).includes(value));
  81. const requests = f.api.calls.length; await f.app.handlers.ksfGetPublicBoard({}); assert.equal(f.api.calls.length, requests, 'public reads never call WeCom');
  82. });
  83. test('unresolved senders stay distinct by stable account and use actual channel display name when available', async () => {
  84. const f = await appFixture(); const registry = f.settings.wecomEmployees; f.settings.wecomEmployees = {};
  85. f.sessions[0].meta.attribution.participantAccount.accountName = '已取得的企微显示名';
  86. const result = await f.app.handlers.ksfGetPublicBoard({});
  87. assert.equal(result.session[0].studentName, '已取得的企微显示名'); assert.equal(result.session[0].employee.nameVerified, false);
  88. assert.ok(result.session[0].employee.key.startsWith('channel-employee:'));
  89. f.settings.wecomDirectory.bots['test-bot'].userIds['opaque-test-sender'] = 'changed-directory-user';
  90. f.settings.wecomEmployees = registry;
  91. assert.equal((await f.app.handlers.ksfGetPublicBoard({})).session[0].employee.name, null);
  92. });
  93. test('sync is scoped, dry by default, rejects caller names, and updates only the private registry with an audit', async () => {
  94. const f = await appFixture(), sync = f.app.handlers.ksfSyncWecomEmployee;
  95. await assert.rejects(() => sync({ sessionIds: ['session1'] }, null), { code: 'AUTH_REQUIRED' });
  96. await assert.rejects(() => sync({ sessionIds: ['session1'], name: '伪造姓名' }, f.actor), { code: 'VALIDATION_FAILED' });
  97. await assert.rejects(() => sync({ sessionIds: ['session1'] }, { id: 'other-service', scopes: ['identity:write'] }), { code: 'FORBIDDEN' });
  98. const dry = await sync({ sessionIds: ['session1', 'session2'] }, f.actor); assert.equal(dry.applied, false); assert.equal(dry.accountsChecked, 1); assert.equal(f.writes.length, 0);
  99. const result = await sync({ sessionIds: ['session1', 'session2'], requestId: 'identity-test-1', apply: true }, f.actor);
  100. assert.equal(result.applied, true); assert.equal(f.writes.length, 1); assert.equal(f.writes[0].kind, 'KsfRuntimeConfig'); assert.equal(f.audits[0].name, 'wecom-employee-sync');
  101. assert.equal(f.runtime.value.unrelatedSetting, 'preserved'); assert.equal(f.sessions[0].studentName, '历史占位名');
  102. });
  103. test('same-name employees are kept separate even in one department, without losing report attribution', () => {
  104. const employee = (id) => ({ key: 'employee:' + id.repeat(64), name: '测试同名', source: 'wecom_directory', nameVerified: true, departmentNames: ['产品部'] });
  105. const raw = { ...blank(), session: [{ objectId: 's1', employee: employee('a'), meta: { participantKey: 'p1' } }, { objectId: 's2', employee: employee('b'), meta: { participantKey: 'p2' } }], artifact: [{ objectId: 'a', ownerSession: 's2' }] };
  106. const db = buildModel(raw); assert.equal(db.members.length, 2); assert.equal(db.members.find((m) => m.name.includes('bbbbbbbb')).artifactIds.length, 1);
  107. });
  108. test('digital-human start and report upload automatically resolve the employee without a separate login or identity API call', async () => {
  109. const f = await appFixture(); f.settings.wecomEmployees = {};
  110. const actor = { id: 'service1', scopes: ['ingest:write'] };
  111. const started = await f.app.handlers.ksfStartWorkshopSession({ requestId: 'start-1', participant: { identityBasis: 'channel_context', account: account() }, agent: { account: { platform: 'wecom', accountId: 'test-bot' } }, conversation: { channel: 'wecom' } }, actor);
  112. assert.equal(started.employee.name, '测试成员甲'); assert.equal(started.employeeIdentity.status, 'verified'); assert.equal(started.employeeIdentity.automatic, true);
  113. const uploaded = await f.app.handlers.ksfSaveWorkshopTask({ requestId: 'save-1', sessionId: 'session1', title: '新报告' }, actor);
  114. assert.equal(uploaded.artifactId, 'new-artifact'); assert.equal(uploaded.employee.name, '测试成员甲'); assert.equal(f.business.length, 2);
  115. assert.equal(f.audits.length, 2); assert.ok(f.audits.every((a) => a.name === 'wecom-employee-sync'));
  116. assert.equal(f.business[1].p.requestId, 'save-1'); assert.equal(f.sessions[0].studentName, '历史占位名');
  117. });
  118. test('upload uses the saved sender account, refuses wrong bot and stops failed configured lookups before saving the report', async () => {
  119. const f = await appFixture();
  120. await assert.rejects(() => f.app.handlers.ksfStartWorkshopSession({ requestId: 'wrong-bot', participant: { identityBasis: 'channel_context', account: account() }, agent: { account: { platform: 'wecom', accountId: 'other-bot' } }, conversation: { channel: 'wecom' } }, f.actor), { code: 'IDENTITY_CONFLICT' });
  121. delete f.settings.wecomDirectory.bots['test-bot'].userIds['opaque-test-sender'];
  122. await assert.rejects(() => f.app.handlers.ksfSaveWorkshopTask({ requestId: 'bad-map', sessionId: 'session1', account: { name: '正文伪造姓名' } }, f.actor), { code: 'WECOM_SENDER_MAPPING_REQUIRED' });
  123. assert.equal(f.business.length, 0); assert.equal(f.writes.length, 0);
  124. });
  125. test('unconfigured name service keeps sender attribution and marks the upload receipt pending instead of claiming a real name', async () => {
  126. const f = await appFixture(); delete f.settings.wecomDirectory; f.settings.wecomEmployees = {};
  127. const result = await f.app.handlers.ksfSaveWorkshopTask({ requestId: 'pending-save', sessionId: 'session1' }, f.actor);
  128. assert.equal(result.artifactId, 'new-artifact'); assert.equal(result.employeeIdentity.status, 'pending'); assert.equal(result.employee.nameVerified, false); assert.equal(f.writes.length, 0);
  129. });