273 lines
13 KiB
JavaScript
Executable File
273 lines
13 KiB
JavaScript
Executable File
#!/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;
|
|
});
|
|
}
|