#!/usr/bin/env node import { pathToFileURL } from 'node:url'; const DEFAULT_TIMEOUT_MS = 20_000; function clientIdFromToken(token) { try { const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); return typeof payload.clientid === 'string' ? payload.clientid : ''; } catch { return ''; } } function authorizationHeaders(token) { const clientId = clientIdFromToken(token); return { Authorization: `Bearer ${token}`, ...(clientId ? { clientid: clientId } : {}) }; } function required(config, key) { const value = config[key]; if (!value) throw new Error(`缺少必需配置 ${key}`); return value; } async function postJson(baseUrl, path, token, body, fetchImpl, timeoutMs) { const response = await fetchImpl(new URL(path, `${baseUrl.replace(/\/$/, '')}/`), { method: 'POST', headers: { ...authorizationHeaders(token), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) }); const payload = await response.json().catch(() => ({})); return { httpStatus: response.status, code: Number(payload.code), message: payload.msg ?? '', data: payload.data }; } async function getJson(baseUrl, path, token, fetchImpl, timeoutMs) { const response = await fetchImpl(new URL(path, `${baseUrl.replace(/\/$/, '')}/`), { headers: authorizationHeaders(token), signal: AbortSignal.timeout(timeoutMs) }); const payload = await response.json().catch(() => ({})); return { httpStatus: response.status, code: Number(payload.code), message: payload.msg ?? '', data: payload.data }; } async function deleteJson(baseUrl, path, token, fetchImpl, timeoutMs) { const response = await fetchImpl(new URL(path, `${baseUrl.replace(/\/$/, '')}/`), { method: 'DELETE', headers: authorizationHeaders(token), signal: AbortSignal.timeout(timeoutMs) }); const payload = await response.json().catch(() => ({})); return { httpStatus: response.status, code: Number(payload.code), message: payload.msg ?? '', data: payload.data }; } function ensureCode(result, expected, label) { if (!expected.includes(result.code)) { throw new Error(`${label}: 期望业务码 ${expected.join('/')},实际 ${result.code || '非JSON响应'} (${result.httpStatus})`); } } function ensureSpaces(result, expected, label) { const used = new Set(result.data?.usedSpaceCodes ?? []); const citations = new Set((result.data?.citations ?? []).map((item) => item.spaceCode).filter(Boolean)); for (const code of expected) { if (!used.has(code) || !citations.has(code)) { throw new Error(`${label}: 响应未同时声明并引用空间 ${code}`); } } } function ensureTool(result, toolCode, label) { const citation = (result.data?.citations ?? []).find((item) => item.sourceType === 'DATA_TOOL'); if (!citation || citation.docId !== toolCode || result.data?.data == null) { throw new Error(`${label}: 未返回受控数据工具 ${toolCode} 的引用与最小结果`); } } export function configFromEnv(env = process.env) { return { AIHR_BASE_URL: env.AIHR_BASE_URL, AIHR_SILVER_ADMIN_TOKEN: env.AIHR_SILVER_ADMIN_TOKEN, AIHR_SILVER_EMPLOYEE_TOKEN: env.AIHR_SILVER_EMPLOYEE_TOKEN, AIHR_SILVER_SUPERVISOR_TOKEN: env.AIHR_SILVER_SUPERVISOR_TOKEN, AIHR_MEITU_APP_TOKEN: env.AIHR_MEITU_APP_TOKEN, AIHR_SHARED_DOC_QUERY: env.AIHR_SHARED_DOC_QUERY || '知识空间共享验证标记', AIHR_MEITU_QUERY: env.AIHR_MEITU_QUERY || '你们提供哪些客户服务?', AIHR_MEITU_RATE_LIMIT: Number(env.AIHR_MEITU_RATE_LIMIT || 60), AIHR_VERIFY_RATE_LIMIT: env.AIHR_VERIFY_RATE_LIMIT !== 'false', AIHR_VERIFY_UNBIND: env.AIHR_VERIFY_UNBIND === 'true', AIHR_SHARED_DOC_NAME: env.AIHR_SHARED_DOC_NAME || '', AIHR_VERIFY_TIMEOUT_MS: Number(env.AIHR_VERIFY_TIMEOUT_MS || DEFAULT_TIMEOUT_MS) }; } export async function verifyKnowledgePlatform(config, { fetchImpl = fetch, log = console.log } = {}) { const baseUrl = required(config, 'AIHR_BASE_URL'); const admin = required(config, 'AIHR_SILVER_ADMIN_TOKEN'); const employee = required(config, 'AIHR_SILVER_EMPLOYEE_TOKEN'); const supervisor = required(config, 'AIHR_SILVER_SUPERVISOR_TOKEN'); const meitu = required(config, 'AIHR_MEITU_APP_TOKEN'); const timeoutMs = Number(config.AIHR_VERIFY_TIMEOUT_MS || DEFAULT_TIMEOUT_MS); const results = []; const check = async (label, operation) => { await operation(); results.push({ label, status: 'PASS' }); log(`PASS ${label}`); }; await check('管理员只看到银城三个标准空间', async () => { const result = await getJson(baseUrl, '/api/knowledge/admin/spaces', admin, fetchImpl, timeoutMs); ensureCode(result, [200], '管理员空间'); const codes = new Set((result.data ?? []).map((item) => item.code)); for (const code of ['yc_public_policy', 'yc_property_sop', 'yc_management_ops']) { if (!codes.has(code)) throw new Error(`管理员空间: 缺少 ${code}`); } if (codes.has('mt_customer_service')) throw new Error('管理员空间: 出现美途跨租户空间'); }); await check('银城员工查询公共制度', async () => { const result = await postJson(baseUrl, '/api/knowledge/query', employee, { queryText: '员工应遵守哪些基本制度?', spaceCodes: ['yc_public_policy'], limit: 5 }, fetchImpl, timeoutMs); ensureCode(result, [200], '员工公共制度'); ensureSpaces(result, ['yc_public_policy'], '员工公共制度'); }); await check('银城员工查询物业业务SOP', async () => { const result = await postJson(baseUrl, '/api/knowledge/query', employee, { queryText: '报修受理后第一步做什么?', spaceCodes: ['yc_property_sop'], limit: 5 }, fetchImpl, timeoutMs); ensureCode(result, [200], '员工物业SOP'); ensureSpaces(result, ['yc_property_sop'], '员工物业SOP'); }); await check('银城主管查询管理运营', async () => { const result = await postJson(baseUrl, '/api/knowledge/query', supervisor, { queryText: '主管怎样组织服务复盘?', spaceCodes: ['yc_management_ops'], limit: 5 }, fetchImpl, timeoutMs); ensureCode(result, [200], '主管管理运营'); ensureSpaces(result, ['yc_management_ops'], '主管管理运营'); }); await check('员工只查询本人训练概况', async () => { const result = await postJson(baseUrl, '/api/knowledge/query', employee, { queryText: '我的训练概况', toolCode: 'MY_PRACTICE_SUMMARY', userId: 'forged-user', extPartyId: 'forged-party' }, fetchImpl, timeoutMs); ensureCode(result, [200], '本人训练概况'); ensureTool(result, 'MY_PRACTICE_SUMMARY', '本人训练概况'); }); await check('主管只查询服务端团队范围', async () => { const result = await postJson(baseUrl, '/api/knowledge/query', supervisor, { queryText: '团队训练概况', toolCode: 'TEAM_PRACTICE_SUMMARY', tenantId: 'forged', extPartyId: 'forged-party' }, fetchImpl, timeoutMs); ensureCode(result, [200], '团队训练概况'); ensureTool(result, 'TEAM_PRACTICE_SUMMARY', '团队训练概况'); }); await check('美途应用只返回美途客户咨询引用', async () => { const result = await postJson(baseUrl, '/api/open/knowledge/query', meitu, { queryText: config.AIHR_MEITU_QUERY || '你们提供哪些客户服务?', spaceCodes: ['mt_customer_service'], limit: 5 }, fetchImpl, timeoutMs); ensureCode(result, [200], '美途客户咨询'); ensureSpaces(result, ['mt_customer_service'], '美途客户咨询'); }); await check('同文件两个银城空间可分别检索', async () => { for (const code of ['yc_public_policy', 'yc_property_sop']) { const result = await postJson(baseUrl, '/api/knowledge/query', employee, { queryText: config.AIHR_SHARED_DOC_QUERY, spaceCodes: [code], limit: 5 }, fetchImpl, timeoutMs); ensureCode(result, [200], `共享文件 ${code}`); ensureSpaces(result, [code], `共享文件 ${code}`); } }); const negativeCases = [ ['银城身份不能请求美途空间', '/api/knowledge/query', employee, { queryText: '越权测试', spaceCodes: ['mt_customer_service'] }, 403], ['美途应用不能请求银城空间', '/api/open/knowledge/query', meitu, { queryText: '越权测试', spaceCodes: ['yc_public_policy'] }, 403], ['员工不能查询团队训练', '/api/knowledge/query', employee, { queryText: '团队训练', toolCode: 'TEAM_PRACTICE_SUMMARY' }, 403], ['外部应用不能调用内部数据工具', '/api/open/knowledge/query', meitu, { queryText: '我的训练', toolCode: 'MY_PRACTICE_SUMMARY' }, 403], ['错误应用令牌被拒绝', '/api/open/knowledge/query', `${meitu}x`, { queryText: '认证测试', spaceCodes: ['mt_customer_service'] }, 401] ]; for (const [label, path, token, body, expected] of negativeCases) { await check(label, async () => { const result = await postJson(baseUrl, path, token, body, fetchImpl, timeoutMs); ensureCode(result, [expected], label); }); } await check('伪造身份字段不改变服务端空间范围', async () => { const result = await postJson(baseUrl, '/api/knowledge/query', employee, { queryText: '员工应遵守哪些基本制度?', spaceCodes: ['yc_public_policy'], tenantId: '100001', userId: '999999', extPartyId: 'supervisor' }, fetchImpl, timeoutMs); ensureCode(result, [200], '伪造身份字段'); ensureSpaces(result, ['yc_public_policy'], '伪造身份字段'); }); if (config.AIHR_VERIFY_RATE_LIMIT !== false) { await check('美途应用超过限流返回429', async () => { const limit = Number(config.AIHR_MEITU_RATE_LIMIT || 60); if (!Number.isInteger(limit) || limit < 1 || limit > 10_000) throw new Error('AIHR_MEITU_RATE_LIMIT 无效'); let saw429 = false; for (let index = 0; index < limit + 1; index += 1) { const result = await postJson(baseUrl, '/api/open/knowledge/query', meitu, { queryText: 'rate-limit-probe', spaceCodes: ['yc_public_policy'] }, fetchImpl, timeoutMs); if (result.code === 429) { saw429 = true; break; } ensureCode(result, [403], '限流探测前置请求'); } if (!saw429) throw new Error(`限流探测: ${limit + 1} 次请求内未出现 429`); }); } if (config.AIHR_VERIFY_UNBIND) { await check('解绑一个空间后其他空间成员与OSS仍保留', async () => { const targetName = required(config, 'AIHR_SHARED_DOC_NAME'); const spacesResult = await getJson(baseUrl, '/api/knowledge/admin/spaces', admin, fetchImpl, timeoutMs); ensureCode(spacesResult, [200], '解绑验证空间列表'); const publicSpace = (spacesResult.data ?? []).find((item) => item.code === 'yc_public_policy'); if (!publicSpace?.id) throw new Error('解绑验证: 找不到公共制度空间ID'); const before = await getJson(baseUrl, `/api/knowledge/admin/spaces/${publicSpace.id}/documents`, admin, fetchImpl, timeoutMs); ensureCode(before, [200], '解绑验证成员列表'); const target = (before.data ?? []).find((item) => item.name === targetName); if (!target?.attachId) throw new Error(`解绑验证: 当前空间找不到文档 ${targetName}`); const removed = await deleteJson(baseUrl, `/api/knowledge/admin/spaces/${publicSpace.id}/documents/${target.attachId}`, admin, fetchImpl, timeoutMs); ensureCode(removed, [200], '解绑当前空间成员'); if (removed.data?.ossDeleted !== false) throw new Error('解绑验证: 仍有其他空间引用时不应删除OSS'); const after = await getJson(baseUrl, `/api/knowledge/admin/spaces/${publicSpace.id}/documents`, admin, fetchImpl, timeoutMs); ensureCode(after, [200], '解绑后成员列表'); if ((after.data ?? []).some((item) => item.attachId === target.attachId)) { throw new Error('解绑验证: 当前空间成员仍然存在'); } const remaining = await postJson(baseUrl, '/api/knowledge/query', employee, { queryText: config.AIHR_SHARED_DOC_QUERY, spaceCodes: ['yc_property_sop'], limit: 5 }, fetchImpl, timeoutMs); ensureCode(remaining, [200], '解绑后其他空间检索'); ensureSpaces(remaining, ['yc_property_sop'], '解绑后其他空间检索'); }); } return results; } async function main() { const results = await verifyKnowledgePlatform(configFromEnv()); console.log(`知识平台验证完成:${results.length}/${results.length} PASS`); } if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { main().catch((error) => { console.error(`知识平台验证失败:${error.message}`); process.exitCode = 1; }); }