|
@@ -0,0 +1,151 @@
|
|
|
|
|
+import test from 'node:test';
|
|
|
|
|
+import assert from 'node:assert/strict';
|
|
|
|
|
+import crypto from 'node:crypto';
|
|
|
|
|
+import fs from 'node:fs';
|
|
|
|
|
+import os from 'node:os';
|
|
|
|
|
+import path from 'node:path';
|
|
|
|
|
+import { attachEmployeeNameHandlers } from '../server/identity/employee-name.mjs';
|
|
|
|
|
+import { attachWecomEmployeeHandlers } from '../server/identity/employee-handlers.mjs';
|
|
|
|
|
+import { buildEmployeeName } from '../server/identity/build-employee-name.mjs';
|
|
|
|
|
+import { manage } from '../server/identity/manage.mjs';
|
|
|
|
|
+import { runWorkshop } from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
|
|
|
|
|
+import { parseArgs } from '../skills/ksf-workshop-runtime/scripts/ksf-secure.mjs';
|
|
|
|
|
+import { employeeCaption } from '../src/core/kfs/employee.js';
|
|
|
|
|
+import { buildModel } from '../src/core/kfs/build.js';
|
|
|
|
|
+import { publishEmployeeName } from '../scripts/publish-employee-name.mjs';
|
|
|
|
|
+
|
|
|
|
|
+const hash = v => crypto.createHash('sha256').update(v).digest('hex');
|
|
|
|
|
+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);
|
|
|
|
|
+const account = { platform: 'wecom', identifierType: 'wecom_aibot_sender', scopeId: 'test-bot', accountId: 'test-sender' };
|
|
|
|
|
+const participant = { key: 'participant-account:' + 'a'.repeat(64), name: '员工待绑定', identityBasis: 'channel_context', account };
|
|
|
|
|
+const agent = { key: 'agent-account:' + 'b'.repeat(64), name: '测试数字人', role: 'workshop', runtimePlatform: 'hermes', channel: 'wecom', account: { platform: 'wecom', accountId: account.scopeId } };
|
|
|
|
|
+const params = { sessionId: 'session1', participantProfileKey: participant.key, agentProfileKey: agent.key, requestId: 'name1' };
|
|
|
|
|
+const keyOf = a => hash(canonical(['wecom_aibot_sender', a.scopeId, a.accountId]));
|
|
|
|
|
+const blank = () => Object.fromEntries(['session', 'artifact', 'project', 'opportunity', 'insight', 'source'].map(k => [k, []]));
|
|
|
|
|
+function fixture() {
|
|
|
|
|
+ const sessions = ['session1', 'session2'].map((id, i) => ({ objectId: id, studentName: '历史占位名', workshopKey: 'w' + i, meta: { ownerId: 'service1', attribution: {
|
|
|
|
|
+ participantKey: 'participant:' + hash(canonical(['w' + i, participant.key])), participantAccount: account, identityBasis: 'channel_context',
|
|
|
|
|
+ agentKey: agent.key, agentName: agent.name, agentRuntimePlatform: agent.runtimePlatform, agentChannel: agent.channel, agentAccount: agent.account,
|
|
|
|
|
+ } } }));
|
|
|
|
|
+ const raw = { ...blank(), session: sessions.map(s => ({ objectId: s.objectId, studentName: s.studentName })),
|
|
|
|
|
+ artifact: [{ objectId: 'artifact1', ownerSession: 'session1', ownerName: '历史占位名', rawText: '已完成报告不重写' }],
|
|
|
|
|
+ project: [{ objectId: 'project1', ownerSession: 'session2', ownerName: '历史占位名' }] };
|
|
|
|
|
+ const writes = [], settings = { unrelated: 'preserved' }, runtime = { objectId: 'runtime1', key: 'runtime', value: structuredClone(settings) }, operations = new Map();
|
|
|
|
|
+ const store = { get: async (kind, id) => structuredClone(sessions.find(s => s.objectId === id)), lock: async () => {}, list: async () => [structuredClone(runtime)],
|
|
|
|
|
+ update: async (kind, id, patch) => { writes.push({ kind, id, patch }); Object.assign(runtime, patch); } };
|
|
|
|
|
+ const fail = code => { throw Object.assign(Error(code), { code }); };
|
|
|
|
|
+ const app = { handlers: { ksfGetPublicBoard: async () => structuredClone(raw) }, hash, canonical, fail,
|
|
|
|
|
+ need: (actor, scope) => { if (!actor) fail('AUTH_REQUIRED'); if (!actor.scopes.some(s => s === scope || s === '*')) fail('FORBIDDEN'); },
|
|
|
|
|
+ operation: async (actor, name, p, fn) => {
|
|
|
|
|
+ const key = actor.id + name + p.requestId, prior = operations.get(key);
|
|
|
|
|
+ if (prior) { if (prior.hash !== hash(canonical(p))) fail('IDEMPOTENCY_CONFLICT'); return prior.result; }
|
|
|
|
|
+ const result = await fn(store); operations.set(key, { hash: hash(canonical(p)), result }); return result;
|
|
|
|
|
+ } };
|
|
|
|
|
+ attachWecomEmployeeHandlers(app, { store, settings, fetchImpl: () => { throw Error('directory must not be called'); } });
|
|
|
|
|
+ attachEmployeeNameHandlers(app, { store, settings });
|
|
|
|
|
+ return { app, sessions, raw, runtime, settings, writes, operations, actor: { id: 'service1', scopes: ['data:read', 'ingest:write'] } };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+test('昵称无需通讯录:Skill读取、保存、回读,跨工作坊显示且历史成果不变', async () => {
|
|
|
|
|
+ const f = fixture(), before = structuredClone({ sessions: f.sessions, raw: f.raw }), task = { sessionId: 'session1', participant: { profileKey: participant.key } };
|
|
|
|
|
+ const run = (input, requestId) => runWorkshop('employee-name', input, { agentProfile: agent, participantProfile: participant, requestId,
|
|
|
|
|
+ call: (name, p) => name === 'ksfGetRecord' ? Promise.resolve({ record: structuredClone(f.sessions[0]) }) : f.app.handlers[name](p, f.actor) });
|
|
|
|
|
+ assert.equal((await run(task, 'read1')).promptState, 'not_asked'); assert.equal(f.writes.length, 0);
|
|
|
|
|
+ const saved = await run({ ...task, displayName: '测试昵称甲' }, 'save1');
|
|
|
|
|
+ assert.equal(saved.displayName, '测试昵称甲'); assert.equal(saved.source, 'self_reported'); assert.equal(saved.nameVerified, false);
|
|
|
|
|
+ assert.equal(saved.persisted, true); assert.equal(saved.businessRecordsChanged, 0);
|
|
|
|
|
+ assert.equal((await run(task, 'read2')).displayName, '测试昵称甲');
|
|
|
|
|
+ assert.equal((await f.app.handlers.ksfEmployeeDisplayName({ ...params, sessionId: 'session2' }, f.actor)).displayName, '测试昵称甲');
|
|
|
|
|
+ const result = await f.app.handlers.ksfGetPublicBoard({}), db = buildModel(result);
|
|
|
|
|
+ assert.equal(db.members.length, 1); assert.equal(db.members[0].name, '测试昵称甲');
|
|
|
|
|
+ assert.equal(result.artifact[0].ownerName, '测试昵称甲'); assert.equal(result.project[0].ownerName, '测试昵称甲');
|
|
|
|
|
+ assert.equal(result.session[0].employee.name, null); assert.equal(result.session[0].employee.nameVerified, false);
|
|
|
|
|
+ assert.match(employeeCaption(result.session[0].employee), /本人提供的称呼/);
|
|
|
|
|
+ assert.deepEqual({ sessions: f.sessions, raw: f.raw }, before); assert.equal(f.runtime.value.unrelated, 'preserved');
|
|
|
|
|
+ assert.deepEqual(f.writes.map(w => w.kind), ['KsfRuntimeConfig']);
|
|
|
|
|
+ assert.ok(!JSON.stringify(result).includes(account.accountId));
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('明确跳过会记住;同请求幂等、改名需新键、同名不合并', async () => {
|
|
|
|
|
+ const f = fixture(), call = p => f.app.handlers.ksfEmployeeDisplayName(p, f.actor);
|
|
|
|
|
+ await call({ ...params, skip: true }); assert.equal((await call(params)).promptState, 'skipped');
|
|
|
|
|
+ await call({ ...params, skip: true }); assert.equal(f.writes.length, 1);
|
|
|
|
|
+ await assert.rejects(() => call({ ...params, displayName: '另一个称呼' }), { code: 'IDEMPOTENCY_CONFLICT' });
|
|
|
|
|
+ await call({ ...params, requestId: 'name2', displayName: '共同昵称' });
|
|
|
|
|
+ await call({ ...params, requestId: 'name3', skip: true }); assert.equal((await call(params)).displayName, '共同昵称');
|
|
|
|
|
+ const second = structuredClone(f.sessions[0]); second.objectId = 'session3'; second.meta.attribution.participantAccount.accountId = 'another-sender'; f.sessions.push(second);
|
|
|
|
|
+ f.raw.session.push({ objectId: 'session3', studentName: '历史名' });
|
|
|
|
|
+ await call({ ...params, sessionId: 'session3', requestId: 'name4', displayName: '共同昵称' });
|
|
|
|
|
+ const db = buildModel(await f.app.handlers.ksfGetPublicBoard({})); assert.equal(db.members.length, 2);
|
|
|
|
|
+ assert.notEqual(db.members[0].employee.key, db.members[1].employee.key);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('已核验通讯录姓名优先,昵称不被当作企微实名或企微显示名', async () => {
|
|
|
|
|
+ const f = fixture(); await f.app.handlers.ksfEmployeeDisplayName({ ...params, displayName: '自报称呼' }, f.actor);
|
|
|
|
|
+ f.settings.wecomDirectory = { enabled: true, corpId: 'test-corp', secret: 'test-secret', bots: { 'test-bot': { mappingVerified: true, senderIdType: 'mapped', userIds: { 'test-sender': 'test-user' } } } };
|
|
|
|
|
+ f.settings.wecomEmployees = { [keyOf(account)]: { version: 'ksf.wecom-employee.v1', key: 'employee:' + hash(canonical(['test-corp', 'test-user'])), source: 'wecom_directory', nameVerified: true, name: '目录姓名', verifiedAt: new Date().toISOString() } };
|
|
|
|
|
+ // Reconstruct like a new server request, which loads fresh runtime settings.
|
|
|
|
|
+ attachWecomEmployeeHandlers(f.app, { store: { get: async (_, id) => f.sessions.find(s => s.objectId === id) }, settings: f.settings });
|
|
|
|
|
+ attachEmployeeNameHandlers(f.app, { store: { get: async (_, id) => f.sessions.find(s => s.objectId === id) }, settings: f.settings });
|
|
|
|
|
+ assert.equal((await f.app.handlers.ksfEmployeeDisplayName(params, f.actor)).displayName, '目录姓名');
|
|
|
|
|
+ const board = await f.app.handlers.ksfGetPublicBoard({}); assert.equal(board.session[0].studentName, '目录姓名');
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('匿名、跨员工/Agent/服务、写权限不足及非法输入在保存前被拒绝', async () => {
|
|
|
|
|
+ for (const [p, actor, code] of [
|
|
|
|
|
+ [params, null, 'AUTH_REQUIRED'], [{ ...params, displayName: '昵称' }, { id: 'service1', scopes: ['data:read'] }, 'FORBIDDEN'],
|
|
|
|
|
+ [params, { id: 'other', scopes: ['data:read'] }, 'FORBIDDEN'],
|
|
|
|
|
+ [{ ...params, participantProfileKey: 'participant-account:' + 'c'.repeat(64) }, undefined, 'IDENTITY_CONFLICT'],
|
|
|
|
|
+ [{ ...params, agentProfileKey: 'agent-account:' + 'c'.repeat(64) }, undefined, 'IDENTITY_CONFLICT'],
|
|
|
|
|
+ [{ ...params, displayName: '名字', skip: true }, undefined, 'VALIDATION_FAILED'],
|
|
|
|
|
+ [{ ...params, displayName: '\n伪造多行' }, undefined, 'VALIDATION_FAILED'],
|
|
|
|
|
+ [{ ...params, displayName: '太'.repeat(41) }, undefined, 'VALIDATION_FAILED'],
|
|
|
|
|
+ [{ ...params, skip: false }, undefined, 'VALIDATION_FAILED'],
|
|
|
|
|
+ ]) {
|
|
|
|
|
+ const f = fixture(); await assert.rejects(() => f.app.handlers.ksfEmployeeDisplayName(p, actor === undefined ? f.actor : actor), { code }); assert.equal(f.writes.length, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ assert.equal(parseArgs(['employee-name', '--input', path.resolve('input.json'), '--request-id', 'test1', '--agent-profile', agent.key]).command, 'employee-name');
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('两函数增量部署可幂等、回退,保留其他四函数并拒绝并发覆盖', async t => {
|
|
|
|
|
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-name-deploy-')); t.after(() => fs.rmSync(dir, { recursive: true }));
|
|
|
|
|
+ const base = { name: 'ksfGetPublicBoard', objectId: 'public1', ACL: {}, code: 'function bootstrap(){attachWecomEmployeeHandlers(app,{store,settings});}\nfunction handler(request,response){return runDatabaseFunction("ksfGetPublicBoard",request,response,{});}' };
|
|
|
|
|
+ const built = buildEmployeeName(base);
|
|
|
|
|
+ built.functions.forEach(f => fs.writeFileSync(path.join(dir, f.name + '.js'), f.code));
|
|
|
|
|
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ ...built, functions: built.functions.map(({ code, ...m }) => m) }));
|
|
|
|
|
+ const rows = [structuredClone(base), ...['ksfReadWecomEmployee', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask'].map(name => ({ name, objectId: name, code: 'preserve', ACL: {} }))], before = structuredClone(rows), writes = [];
|
|
|
|
|
+ const request = async (method, route, body) => {
|
|
|
|
|
+ if (route.includes('?')) return { results: structuredClone(rows.filter(r => r.name === JSON.parse(new URL('https://test' + route).searchParams.get('where')).name)) };
|
|
|
|
|
+ const id = route.split('/').at(-1);
|
|
|
|
|
+ if (method === 'GET') return structuredClone(rows.find(r => r.objectId === id));
|
|
|
|
|
+ writes.push({ method, id });
|
|
|
|
|
+ if (method === 'POST') { rows.push({ objectId: 'nameFn', ...body }); return { objectId: 'nameFn' }; }
|
|
|
|
|
+ if (method === 'PUT') { Object.assign(rows.find(r => r.objectId === id), body); return {}; }
|
|
|
|
|
+ if (method === 'DELETE') { rows.splice(rows.findIndex(r => r.objectId === id), 1); return {}; }
|
|
|
|
|
+ };
|
|
|
|
|
+ const run = args => manage({ args, request, releaseDir: dir });
|
|
|
|
|
+ assert.equal((await run(['deploy-employee-name'])).readOnly, true); assert.equal(writes.length, 0);
|
|
|
|
|
+ const saved = await run(['deploy-employee-name', '--apply', '--receipt-dir', dir]); assert.equal(saved.functions, 2);
|
|
|
|
|
+ assert.deepEqual(rows.slice(1, 5), before.slice(1)); assert.equal((await run(['deploy-employee-name', '--apply', '--receipt-dir', dir])).unchanged, true);
|
|
|
|
|
+ await run(['rollback', '--receipt', saved.receipt, '--apply']); assert.deepEqual(rows, before);
|
|
|
|
|
+ rows[0].code += '// concurrent'; await assert.rejects(() => run(['deploy-employee-name']), { code: 'FUNCTION_BASELINE_MISMATCH' });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('一文件静态补丁先预检、可回退、未知改动不覆盖', async t => {
|
|
|
|
|
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-name-static-')); t.after(() => fs.rmSync(dir, { recursive: true }));
|
|
|
|
|
+ const sourceDir = path.join(dir, 'package'), targetDir = path.join(dir, 'site'), file = 'src/core/kfs/employee.js';
|
|
|
|
|
+ // This is the exact deployed r5 file; derive its old label from the maintained source.
|
|
|
|
|
+ const next = fs.readFileSync(new URL('../src/core/kfs/employee.js', import.meta.url), 'utf8').replaceAll('\r\n', '\n');
|
|
|
|
|
+ const prior = next.replace("employee.source === 'self_reported' && employee.displayName ? '本人提供的称呼 · 未经通讯录核验' : ", '');
|
|
|
|
|
+ assert.equal(hash(prior), 'd2b312ac7ae6c749279a9c04c9e87e697b86c269e503ab3009c6e1b69c852eb1');
|
|
|
|
|
+ for (const root of [path.join(sourceDir, 'payload'), targetDir]) fs.mkdirSync(path.join(root, 'src/core/kfs'), { recursive: true });
|
|
|
|
|
+ fs.mkdirSync(path.join(sourceDir, 'deploy')); fs.writeFileSync(path.join(targetDir, 'index.html'), 'preserved-home');
|
|
|
|
|
+ fs.writeFileSync(path.join(targetDir, file), prior); fs.writeFileSync(path.join(sourceDir, 'payload', file), next);
|
|
|
|
|
+ fs.writeFileSync(path.join(sourceDir, 'deploy/employee-name-static.json'), JSON.stringify({ release: 'ksf.employee-name.20261103.v1', path: file, prior: hash(prior), sha256: hash(next) }));
|
|
|
|
|
+ const run = extra => publishEmployeeName({ sourceDir, targetDir, ...extra });
|
|
|
|
|
+ assert.equal(run().readOnly, true); assert.equal(fs.readFileSync(path.join(targetDir, file), 'utf8'), prior);
|
|
|
|
|
+ const applied = run({ apply: true }); assert.equal(applied.filesystemVerified, true); assert.equal(run({ apply: true }).changes.length, 0);
|
|
|
|
|
+ run({ backupFile: applied.backup, apply: true }); assert.equal(fs.readFileSync(path.join(targetDir, file), 'utf8'), prior);
|
|
|
|
|
+ fs.appendFileSync(path.join(targetDir, file), '// maintainer'); assert.throws(() => run({ apply: true }), { code: 'UNKNOWN_TARGET_CONTENT' });
|
|
|
|
|
+ assert.equal(fs.readFileSync(path.join(targetDir, 'index.html'), 'utf8'), 'preserved-home');
|
|
|
|
|
+});
|