Files
prop-ai-hr/scripts/tests/knowledge-platform-security.test.mjs

120 lines
5.7 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import http from 'node:http';
import test from 'node:test';
import { once } from 'node:events';
import { verifyKnowledgePlatform } from '../verify-knowledge-platform.mjs';
test('真实HTTP验证器覆盖正例、跨租户反例、伪造身份和限流', async (t) => {
const rateLimit = 3;
let rateProbes = 0;
const server = http.createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {};
const token = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '');
let code = 200;
let data = null;
if (request.url === '/api/knowledge/admin/spaces') {
data = token === 'admin-token'
? ['yc_public_policy', 'yc_property_sop', 'yc_management_ops'].map((value) => ({ code: value }))
: null;
code = data ? 200 : 401;
} else if (token.endsWith('x') || !['employee-token', 'supervisor-token', 'meitu-token'].includes(token)) {
code = 401;
} else if (body.queryText === 'rate-limit-probe') {
rateProbes += 1;
code = rateProbes > rateLimit ? 429 : 403;
} else if (request.url === '/api/open/knowledge/query' && body.toolCode) {
code = 403;
} else if (body.toolCode === 'TEAM_PRACTICE_SUMMARY' && token === 'employee-token') {
code = 403;
} else if (body.toolCode) {
data = {
citations: [{ sourceType: 'DATA_TOOL', docId: body.toolCode }],
data: { scope: token === 'employee-token' ? 'SELF' : 'TEAM' }
};
} else {
const codeRequested = body.spaceCodes?.[0];
const isMeitu = token === 'meitu-token';
const allowed = isMeitu
? codeRequested === 'mt_customer_service'
: ['yc_public_policy', 'yc_property_sop', 'yc_management_ops'].includes(codeRequested);
const roleAllowed = codeRequested !== 'yc_management_ops' || token === 'supervisor-token';
if (!allowed || !roleAllowed) {
code = 403;
} else {
data = {
usedSpaceCodes: [codeRequested],
citations: [{ sourceType: 'DOCUMENT', spaceCode: codeRequested }]
};
}
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ code, msg: code === 200 ? '操作成功' : 'rejected', data }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => server.close());
const address = server.address();
const logs = [];
const results = await verifyKnowledgePlatform({
AIHR_BASE_URL: `http://127.0.0.1:${address.port}`,
AIHR_SILVER_ADMIN_TOKEN: 'admin-token',
AIHR_SILVER_EMPLOYEE_TOKEN: 'employee-token',
AIHR_SILVER_SUPERVISOR_TOKEN: 'supervisor-token',
AIHR_MEITU_APP_TOKEN: 'meitu-token',
AIHR_SHARED_DOC_QUERY: 'shared-marker',
AIHR_MEITU_RATE_LIMIT: rateLimit,
AIHR_VERIFY_RATE_LIMIT: true,
AIHR_VERIFY_TIMEOUT_MS: 2_000
}, { log: (line) => logs.push(line) });
assert.equal(results.length, 15);
assert.equal(results.every((item) => item.status === 'PASS'), true);
assert.equal(logs.some((line) => /token/.test(line)), false);
assert.equal(rateProbes, rateLimit + 1);
});
test('服务端安全合同在RAG前拒绝空范围,且身份只取认证上下文', async () => {
const root = new URL('../../', import.meta.url);
const queryService = await readFile(new URL(
'backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java', root), 'utf8');
const queryDto = await readFile(new URL(
'backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java', root), 'utf8');
const accessService = await readFile(new URL(
'backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAccessService.java', root), 'utf8');
const appService = await readFile(new URL(
'backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java', root), 'utf8');
assert.doesNotMatch(queryDto, /tenantId|userId|extPartyId/);
assert.match(queryService, /principalResolver\.current\(\)/);
assert.match(queryService, /TenantHelper\.dynamic\(app\.tenantId\(\)/);
assert.match(queryService, /resolveInternalSpaceIds[\s\S]*queryDocuments/);
assert.match(accessService, /if \(effective\.isEmpty\(\)\)[\s\S]*throw forbidden/);
assert.match(accessService, /where s\.tenant_id = \? and s\.app_id = \?/);
assert.match(accessService, /where g\.tenant_id = \?/);
assert.match(appService, /expiresTime\(\) == null \|\| row\.expiresTime\(\)\.isAfter/);
assert.match(appService, /RateType\.OVERALL, rate, 60/);
});
test('验证器从内部JWT自动携带clientid且不要求额外密钥配置', async () => {
const source = await readFile(new URL('../verify-knowledge-platform.mjs', import.meta.url), 'utf8');
assert.match(source, /payload\.clientid/);
assert.match(source, /clientid: clientId/);
assert.doesNotMatch(source, /AIHR_.*CLIENT_ID/);
});
test('破坏性解绑验证默认关闭且本地运行器最终撤销临时令牌', async () => {
const verifier = await readFile(new URL('../verify-knowledge-platform.mjs', import.meta.url), 'utf8');
const localRunner = await readFile(new URL('../verify-knowledge-platform-local.mjs', import.meta.url), 'utf8');
assert.match(verifier, /AIHR_VERIFY_UNBIND: env\.AIHR_VERIFY_UNBIND === 'true'/);
assert.match(verifier, /removed\.data\?\.ossDeleted !== false/);
assert.match(verifier, /解绑后其他空间检索/);
assert.match(localRunner, /finally \{[\s\S]*disableTemporaryMeituToken\(\)/);
assert.doesNotMatch(localRunner, /console\.log\([^\n]*externalToken/);
});