|
@@ -0,0 +1,107 @@
|
|
|
|
|
+import test from 'node:test';
|
|
|
|
|
+import assert from 'node:assert/strict';
|
|
|
|
|
+import crypto from 'node:crypto';
|
|
|
|
|
+import { createWecomDirectory } from '../server/identity/wecom-directory.mjs';
|
|
|
|
|
+import { attachWecomEmployeeHandlers } from '../server/identity/employee-handlers.mjs';
|
|
|
|
|
+import { buildModel } from '../src/core/kfs/build.js';
|
|
|
|
|
+import { employeeCaption } from '../src/core/kfs/employee.js';
|
|
|
|
|
+
|
|
|
|
|
+const canonical = (v) => JSON.stringify(v);
|
|
|
|
|
+const hash = (v) => crypto.createHash('sha256').update(v).digest('hex');
|
|
|
|
|
+const account = (bot = 'test-bot') => ({ platform: 'wecom', identifierType: 'wecom_aibot_sender', scopeId: bot, accountId: 'opaque-test-sender' });
|
|
|
|
|
+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' } } } });
|
|
|
|
|
+const blank = () => Object.fromEntries(['session', 'artifact', 'project', 'opportunity', 'insight', 'source'].map((k) => [k, []]));
|
|
|
|
|
+function fixtureApi({ user = {}, departmentError = false, tokenError = false } = {}) {
|
|
|
|
|
+ const calls = [];
|
|
|
|
|
+ const fetchImpl = async (url, options) => {
|
|
|
|
|
+ calls.push({ url, options }); const path = new URL(url).pathname;
|
|
|
|
|
+ const body = path.endsWith('/gettoken') ? tokenError ? { errcode: 40001, errmsg: 'contains fixture-only-secret' } : { errcode: 0, access_token: 'fixture-access-token', expires_in: 7200 }
|
|
|
|
|
+ : 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 }
|
|
|
|
|
+ : departmentError ? { errcode: 48002 } : { errcode: 0, department: [{ id: 3, name: '测试产品部' }] };
|
|
|
|
|
+ return { ok: true, json: async () => body };
|
|
|
|
|
+ };
|
|
|
|
|
+ return { fetchImpl, calls };
|
|
|
|
|
+}
|
|
|
|
|
+function directory(api = fixtureApi(), value = config()) {
|
|
|
|
|
+ return { ...api, resolver: createWecomDirectory({ config: value, hash, canonical, fetchImpl: api.fetchImpl, now: () => Date.parse('2026-11-03T00:00:00Z') }) };
|
|
|
|
|
+}
|
|
|
|
|
+test('aibot sender requires verified scope and explicit UserID mapping before any network request', async () => {
|
|
|
|
|
+ for (const cfg of [null, {}, { ...config(), bots: {} }, { ...config(), bots: { 'test-bot': { senderIdType: 'directory_userid' } } }]) {
|
|
|
|
|
+ const f = directory(fixtureApi(), cfg);
|
|
|
|
|
+ await assert.rejects(() => f.resolver.resolve(account()), /WECOM_(?:DIRECTORY_NOT_CONFIGURED|SENDER_MAPPING_REQUIRED)/);
|
|
|
|
|
+ assert.equal(f.calls.length, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+test('directory supplies name and available business fields, without returning credentials or contact identifiers', async () => {
|
|
|
|
|
+ const f = directory(), p = await f.resolver.resolve(account());
|
|
|
|
|
+ assert.equal(p.name, '测试成员甲'); assert.equal(p.displayName, '测试昵称');
|
|
|
|
|
+ assert.deepEqual(p.departmentNames, ['测试产品部']); assert.equal(p.position, '产品经理');
|
|
|
|
|
+ assert.equal(p.nameVerified, true); assert.equal(p.source, 'wecom_directory');
|
|
|
|
|
+ for (const secret of ['fixture-only-secret', 'fixture-access-token', 'directory-user-1', 'must-not-be-public']) assert.ok(!JSON.stringify(p).includes(secret));
|
|
|
|
|
+ await f.resolver.resolve(account()); assert.equal(f.calls.length, 3, 'one member lookup is reused within a sync');
|
|
|
|
|
+ assert.ok(f.calls.every((c) => c.options.redirect === 'error'));
|
|
|
|
|
+});
|
|
|
|
|
+test('optional department permission does not discard a verified name; inactive or mismatched members are rejected', async () => {
|
|
|
|
|
+ const f = directory(fixtureApi({ departmentError: true }));
|
|
|
|
|
+ assert.equal((await f.resolver.resolve(account())).name, '测试成员甲');
|
|
|
|
|
+ for (const user of [{ userid: 'another-user' }, { status: 2 }, { name: '' }]) await assert.rejects(() => directory(fixtureApi({ user })).resolver.resolve(account()), { code: 'WECOM_DIRECTORY_MEMBER_UNAVAILABLE' });
|
|
|
|
|
+ await assert.rejects(() => directory(fixtureApi({ tokenError: true })).resolver.resolve(account()), (e) => e.code === 'WECOM_DIRECTORY_API_ERROR' && !e.message.includes('fixture-only-secret'));
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+async function appFixture() {
|
|
|
|
|
+ const api = fixtureApi(), settings = { wecomDirectory: config(), unrelatedSetting: 'preserved' };
|
|
|
|
|
+ const resolver = directory(api, settings.wecomDirectory).resolver;
|
|
|
|
|
+ const profile = await resolver.resolve(account());
|
|
|
|
|
+ settings.wecomEmployees = { [resolver.accountKey(account())]: profile };
|
|
|
|
|
+ const sessions = [
|
|
|
|
|
+ { objectId: 'session1', studentName: '历史占位名', workshopKey: 'workshop1', meta: { ownerId: 'service1', attribution: { participantAccount: account() } } },
|
|
|
|
|
+ { objectId: 'session2', studentName: '历史占位名', workshopKey: 'workshop2', meta: { ownerId: 'service1', attribution: { participantAccount: account() } } },
|
|
|
|
|
+ ];
|
|
|
|
|
+ const raw = { ...blank(), session: sessions.map((s, i) => ({ objectId: s.objectId, studentName: s.studentName, meta: { participantKey: 'workshop-participant-' + i } })),
|
|
|
|
|
+ artifact: [{ objectId: 'a', ownerName: '历史占位名', ownerSession: 'session1', rawText: '正文不能修改' }], project: [{ objectId: 'p', ownerName: '历史占位名', ownerSession: 'session2' }] };
|
|
|
|
|
+ const writes = [], audits = [];
|
|
|
|
|
+ const runtime = { objectId: 'runtime-id', key: 'runtime', value: structuredClone(settings) };
|
|
|
|
|
+ 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); } };
|
|
|
|
|
+ const fail = (code) => { throw Object.assign(new Error(code), { code }); };
|
|
|
|
|
+ const app = { handlers: { ksfGetPublicBoard: async () => structuredClone(raw) }, fail, hash, canonical,
|
|
|
|
|
+ need: (actor, scope) => { if (!actor) fail('AUTH_REQUIRED'); if (!actor.scopes.some((s) => s === '*' || s === scope)) fail('FORBIDDEN'); },
|
|
|
|
|
+ operation: async (actor, name, p, fn) => { audits.push({ name, p }); return fn(store); } };
|
|
|
|
|
+ attachWecomEmployeeHandlers(app, { store, settings, fetchImpl: api.fetchImpl });
|
|
|
|
|
+ return { app, raw, sessions, writes, audits, settings, api, runtime, actor: { id: 'admin', scopes: ['*'] } };
|
|
|
|
|
+}
|
|
|
|
|
+test('public overlay supplies directory names and stable identity across workshops while preserving history', async () => {
|
|
|
|
|
+ const f = await appFixture(), before = structuredClone(f.raw), result = await f.app.handlers.ksfGetPublicBoard({});
|
|
|
|
|
+ assert.equal(result.artifact[0].ownerName, '测试成员甲'); assert.equal(result.project[0].ownerName, '测试成员甲');
|
|
|
|
|
+ assert.equal(result.session[0].employee.key, result.session[1].employee.key);
|
|
|
|
|
+ const db = buildModel(result); assert.equal(db.members.length, 1); assert.equal(db.members[0].name, '测试成员甲');
|
|
|
|
|
+ assert.equal(db.members[0].artifactIds.length, 1); assert.equal(db.members[0].projectIds.length, 1);
|
|
|
|
|
+ assert.ok(employeeCaption(db.members[0].employee).includes('测试产品部')); assert.ok(employeeCaption(db.members[0].employee).includes('产品经理'));
|
|
|
|
|
+ assert.deepEqual(f.raw, before); assert.equal(f.writes.length, 0);
|
|
|
|
|
+ for (const value of ['fixture-only-secret', 'opaque-test-sender', 'directory-user-1', 'must-not-be-public']) assert.ok(!JSON.stringify(result).includes(value));
|
|
|
|
|
+ const requests = f.api.calls.length; await f.app.handlers.ksfGetPublicBoard({}); assert.equal(f.api.calls.length, requests, 'public reads never call WeCom');
|
|
|
|
|
+});
|
|
|
|
|
+test('unresolved senders stay distinct by stable account and use actual channel display name when available', async () => {
|
|
|
|
|
+ const f = await appFixture(); const registry = f.settings.wecomEmployees; f.settings.wecomEmployees = {};
|
|
|
|
|
+ f.sessions[0].meta.attribution.participantAccount.accountName = '已取得的企微显示名';
|
|
|
|
|
+ const result = await f.app.handlers.ksfGetPublicBoard({});
|
|
|
|
|
+ assert.equal(result.session[0].studentName, '已取得的企微显示名'); assert.equal(result.session[0].employee.nameVerified, false);
|
|
|
|
|
+ assert.ok(result.session[0].employee.key.startsWith('channel-employee:'));
|
|
|
|
|
+ f.settings.wecomDirectory.bots['test-bot'].userIds['opaque-test-sender'] = 'changed-directory-user';
|
|
|
|
|
+ f.settings.wecomEmployees = registry;
|
|
|
|
|
+ assert.equal((await f.app.handlers.ksfGetPublicBoard({})).session[0].employee.name, null);
|
|
|
|
|
+});
|
|
|
|
|
+test('sync is scoped, dry by default, rejects caller names, and updates only the private registry with an audit', async () => {
|
|
|
|
|
+ const f = await appFixture(), sync = f.app.handlers.ksfSyncWecomEmployee;
|
|
|
|
|
+ await assert.rejects(() => sync({ sessionIds: ['session1'] }, null), { code: 'AUTH_REQUIRED' });
|
|
|
|
|
+ await assert.rejects(() => sync({ sessionIds: ['session1'], name: '伪造姓名' }, f.actor), { code: 'VALIDATION_FAILED' });
|
|
|
|
|
+ await assert.rejects(() => sync({ sessionIds: ['session1'] }, { id: 'other-service', scopes: ['identity:write'] }), { code: 'FORBIDDEN' });
|
|
|
|
|
+ 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);
|
|
|
|
|
+ const result = await sync({ sessionIds: ['session1', 'session2'], requestId: 'identity-test-1', apply: true }, f.actor);
|
|
|
|
|
+ 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');
|
|
|
|
|
+ assert.equal(f.runtime.value.unrelatedSetting, 'preserved'); assert.equal(f.sessions[0].studentName, '历史占位名');
|
|
|
|
|
+});
|
|
|
|
|
+test('same-name employees are kept separate even in one department, without losing report attribution', () => {
|
|
|
|
|
+ const employee = (id) => ({ key: 'employee:' + id.repeat(64), name: '测试同名', source: 'wecom_directory', nameVerified: true, departmentNames: ['产品部'] });
|
|
|
|
|
+ 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' }] };
|
|
|
|
|
+ const db = buildModel(raw); assert.equal(db.members.length, 2); assert.equal(db.members.find((m) => m.name.includes('bbbbbbbb')).artifactIds.length, 1);
|
|
|
|
|
+});
|