|
|
@@ -0,0 +1,134 @@
|
|
|
+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 { attachWecomEmployeeReadHandler } from '../server/identity/employee-read.mjs';
|
|
|
+import { buildEmployeeRead } from '../server/identity/build-employee-read.mjs';
|
|
|
+import { manage } from '../server/identity/manage.mjs';
|
|
|
+import { runWorkshop, createWorkshopCaller } from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
|
|
|
+import { parseArgs } from '../skills/ksf-workshop-runtime/scripts/ksf-secure.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: 'private-bot', accountId: 'private-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: 'read1' };
|
|
|
+function fixture({ configured = true, apiError = false } = {}) {
|
|
|
+ const calls = [], writes = [];
|
|
|
+ const session = { objectId: 'session1', workshopKey: 'test-workshop', meta: { ownerId: 'service1', attribution: {
|
|
|
+ participantKey: 'participant:' + hash(canonical(['test-workshop', participant.key])), participantAccount: account, identityBasis: 'channel_context',
|
|
|
+ agentKey: agent.key, agentName: agent.name, agentRuntimePlatform: agent.runtimePlatform, agentChannel: agent.channel, agentAccount: agent.account,
|
|
|
+ } } };
|
|
|
+ const fail = code => { throw Object.assign(Error(code), { code }); };
|
|
|
+ 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'); } };
|
|
|
+ const config = { enabled: true, corpId: 'private-corp', secret: 'fixture-secret', bots: { 'private-bot': { mappingVerified: true, senderIdType: 'mapped', userIds: { 'private-sender': 'private-user' } } } };
|
|
|
+ const fetchImpl = async url => {
|
|
|
+ calls.push(url);
|
|
|
+ const body = apiError ? { errcode: 48002, errmsg: 'fixture-secret must not appear in error' } : new URL(url).pathname.endsWith('/gettoken')
|
|
|
+ ? { errcode: 0, access_token: 'fixture-access-token', expires_in: 7200 }
|
|
|
+ : { errcode: 0, userid: 'private-user', status: 1, name: '测试员工甲', department: [], mobile: 'private-mobile' };
|
|
|
+ return { ok: true, json: async () => body };
|
|
|
+ };
|
|
|
+ const store = { get: async (kind, id) => kind === 'KfSession' && id === 'session1' ? structuredClone(session) : null,
|
|
|
+ update: () => { writes.push('update'); throw Error('must not write'); } };
|
|
|
+ const settings = configured ? { wecomDirectory: config } : {};
|
|
|
+ attachWecomEmployeeReadHandler(app, { store, settings, fetchImpl });
|
|
|
+ return { app, session, settings, store, calls, writes, actor: { id: 'service1', scopes: ['data:read'] } };
|
|
|
+}
|
|
|
+
|
|
|
+test('employee-read从Skill到服务端实际查询姓名,沿用data:read且服务端零写入', async () => {
|
|
|
+ const f = fixture(), calls = [];
|
|
|
+ const result = await runWorkshop('employee-read', { sessionId: 'session1', participant: { profileKey: participant.key } }, {
|
|
|
+ requestId: 'minimal-read', agentProfile: agent, participantProfile: participant,
|
|
|
+ call: async (name, p) => { calls.push({ name, p }); return name === 'ksfGetRecord' ? { record: structuredClone(f.session) } : f.app.handlers[name](p, f.actor); },
|
|
|
+ });
|
|
|
+ assert.deepEqual(calls.map(c => c.name), ['ksfGetRecord', 'ksfReadWecomEmployee']);
|
|
|
+ assert.equal(calls[1].p.requestId, 'minimal-read');
|
|
|
+ assert.equal(result.employee.name, '测试员工甲'); assert.equal(result.employee.nameVerified, true);
|
|
|
+ assert.equal(result.readOnly, true); assert.equal(result.databaseWrites, 0); assert.equal(result.employeeIdentity.persisted, false);
|
|
|
+ assert.equal(f.calls.length, 2); assert.deepEqual(f.writes, []);
|
|
|
+ for (const secret of ['fixture-secret', 'fixture-access-token', 'private-user', 'private-mobile', 'private-bot', 'private-sender']) assert.ok(!JSON.stringify(result).includes(secret));
|
|
|
+});
|
|
|
+
|
|
|
+test('匿名、其他服务、其他员工、其他Agent或无读取权限在通讯录请求前被拒绝', async () => {
|
|
|
+ for (const [input, actor, code] of [
|
|
|
+ [params, null, 'AUTH_REQUIRED'], [params, { id: 'service1', scopes: ['ingest:write'] }, 'FORBIDDEN'],
|
|
|
+ [params, { id: 'another', scopes: ['data:read'] }, 'FORBIDDEN'],
|
|
|
+ [{ ...params, participantProfileKey: 'participant-account:' + 'c'.repeat(64) }, null, 'IDENTITY_CONFLICT'],
|
|
|
+ [{ ...params, agentProfileKey: 'agent-account:' + 'c'.repeat(64) }, null, 'IDENTITY_CONFLICT'],
|
|
|
+ [{ ...params, apply: true }, null, 'VALIDATION_FAILED'],
|
|
|
+ ]) {
|
|
|
+ const f = fixture();
|
|
|
+ const effectiveActor = actor || (code === 'AUTH_REQUIRED' ? null : f.actor);
|
|
|
+ await assert.rejects(() => f.app.handlers.ksfReadWecomEmployee(input, effectiveActor), { code });
|
|
|
+ assert.equal(f.calls.length, 0); assert.deepEqual(f.writes, []);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('缺配置、缺映射和无通讯录权限给出真实错误,不退回看板占位名', async () => {
|
|
|
+ const absent = fixture({ configured: false });
|
|
|
+ await assert.rejects(() => absent.app.handlers.ksfReadWecomEmployee(params, absent.actor), { code: 'WECOM_DIRECTORY_NOT_CONFIGURED' });
|
|
|
+ assert.equal(absent.calls.length, 0);
|
|
|
+ const mapping = fixture(); mapping.settings.wecomDirectory.bots['private-bot'].userIds = {};
|
|
|
+ await assert.rejects(() => mapping.app.handlers.ksfReadWecomEmployee(params, mapping.actor), { code: 'WECOM_SENDER_MAPPING_REQUIRED' });
|
|
|
+ assert.equal(mapping.calls.length, 0);
|
|
|
+ const denied = fixture({ apiError: true });
|
|
|
+ await assert.rejects(() => denied.app.handlers.ksfReadWecomEmployee(params, denied.actor), e => e.code === 'WECOM_DIRECTORY_PERMISSION_REQUIRED' && !e.message.includes('fixture-secret'));
|
|
|
+ assert.deepEqual(denied.writes, []);
|
|
|
+});
|
|
|
+
|
|
|
+test('CLI白名单接受employee-read但仍要求Agent,错员工在远端姓名查询前停止', async () => {
|
|
|
+ const input = path.resolve('input.json');
|
|
|
+ assert.equal(parseArgs(['employee-read', '--input', input, '--request-id', 'r1', '--agent-profile', agent.key]).command, 'employee-read');
|
|
|
+ assert.throws(() => parseArgs(['employee-read', '--input', input, '--request-id', 'r1']), { code: 'AGENT_PROFILE_REQUIRED' });
|
|
|
+ const f = fixture(), calls = [];
|
|
|
+ await assert.rejects(() => runWorkshop('employee-read', { sessionId: 'session1', participant: { profileKey: participant.key } }, {
|
|
|
+ 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' } } } }; },
|
|
|
+ }), { code: 'PARTICIPANT_SESSION_MISMATCH' });
|
|
|
+ assert.deepEqual(calls, ['ksfGetRecord']);
|
|
|
+});
|
|
|
+
|
|
|
+test('受权网络调用保留远端函数及真实错误码且不自动重试', async () => {
|
|
|
+ const calls = [];
|
|
|
+ const caller = createWorkshopCaller({ token: 'fixture-token', fetchImpl: async (url, options) => {
|
|
|
+ calls.push({ url, options });
|
|
|
+ return { ok: true, json: async () => ({ result: { ok: false, errors: [{ code: 'WECOM_DIRECTORY_NOT_CONFIGURED', message: 'private-secret' }] } }) };
|
|
|
+ } });
|
|
|
+ await assert.rejects(() => caller('ksfReadWecomEmployee', params), e => e.code === 'WECOM_DIRECTORY_NOT_CONFIGURED' && e.remoteFunction === 'ksfReadWecomEmployee' && e.remoteResponseReceived === true && !e.message.includes('private-secret'));
|
|
|
+ assert.equal(calls.length, 1); assert.equal(JSON.parse(calls[0].options.body).path, '/ksfReadWecomEmployee');
|
|
|
+});
|
|
|
+
|
|
|
+test('单函数补丁只新增读取函数,部署可重复、可回退且保留r5四函数', async t => {
|
|
|
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-employee-read-'));
|
|
|
+ t.after(() => fs.rmSync(dir, { recursive: true }));
|
|
|
+ const base = { name: 'ksfGetPublicBoard', objectId: 'public1', path: '/ksfGetPublicBoard', ACL: {},
|
|
|
+ code: 'function bootstrap(){attachWecomEmployeeHandlers(app,{store,settings});}\nfunction handler(request,response){return runDatabaseFunction("ksfGetPublicBoard",request,response,{});}' };
|
|
|
+ const built = buildEmployeeRead(base);
|
|
|
+ new Function(built.functions[0].code);
|
|
|
+ for (const entry of built.functions) fs.writeFileSync(path.join(dir, entry.name + '.js'), entry.code);
|
|
|
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ ...built, functions: built.functions.map(({ code, ...meta }) => meta) }));
|
|
|
+ const rows = [structuredClone(base), ...['ksfStartWorkshopSession', 'ksfSaveWorkshopTask', 'ksfSyncWecomEmployee'].map(name => ({ name, objectId: name, code: 'preserved', ACL: {} }))];
|
|
|
+ const 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, route, body });
|
|
|
+ if (method === 'POST') { rows.push({ objectId: 'read1', ...body }); return { objectId: 'read1' }; }
|
|
|
+ if (method === 'DELETE') { rows.splice(rows.findIndex(r => r.objectId === id), 1); return {}; }
|
|
|
+ throw Error('must not edit existing functions');
|
|
|
+ };
|
|
|
+ const run = args => manage({ args, request, releaseDir: dir });
|
|
|
+ const dry = await run(['deploy-employee-read']); assert.equal(dry.readOnly, true); assert.equal(writes.length, 0);
|
|
|
+ const applied = await run(['deploy-employee-read', '--apply', '--receipt-dir', dir]);
|
|
|
+ assert.equal(applied.functions, 1); assert.equal(writes.length, 1); assert.equal(rows.length, 5);
|
|
|
+ assert.deepEqual(rows.slice(0, 4), before);
|
|
|
+ assert.equal((await run(['deploy-employee-read', '--apply', '--receipt-dir', dir])).unchanged, true); assert.equal(writes.length, 1);
|
|
|
+ await run(['rollback', '--receipt', applied.receipt, '--apply']); assert.deepEqual(rows, before);
|
|
|
+ assert.throws(() => buildEmployeeRead({ ...base, code: base.code.replace('attachWecomEmployeeHandlers', 'changed') }), /UNEXPECTED_R5_BASE/);
|
|
|
+ rows[0].code += '// concurrent edit';
|
|
|
+ await assert.rejects(() => run(['deploy-employee-read']), { code: 'FUNCTION_BASELINE_MISMATCH' });
|
|
|
+});
|