219 lines
9.7 KiB
JavaScript
Executable File
219 lines
9.7 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import crypto from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { verifyKnowledgePlatform } from './verify-knowledge-platform.mjs';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const baseUrl = process.env.AIHR_BASE_URL || 'https://wygj-api.localhost';
|
|
const frontendEnv = readEnv(path.join(root, 'frontend/.env.development'));
|
|
const adminClientId = frontendEnv.VITE_APP_CLIENT_ID;
|
|
const requestPublicKey = pem('PUBLIC KEY', frontendEnv.VITE_APP_RSA_PUBLIC_KEY);
|
|
const responsePrivateKey = pem('PRIVATE KEY', frontendEnv.VITE_APP_RSA_PRIVATE_KEY);
|
|
|
|
if (new URL(baseUrl).hostname.endsWith('.localhost')) {
|
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
}
|
|
|
|
function readEnv(filePath) {
|
|
const values = {};
|
|
for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
|
|
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
|
|
if (match) values[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function pem(label, body) {
|
|
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
|
|
}
|
|
|
|
function encryptPayload(payload) {
|
|
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
|
|
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
|
|
cipher.setAutoPadding(true);
|
|
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]).toString('base64');
|
|
const encryptedKey = crypto.publicEncrypt(
|
|
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
|
|
Buffer.from(Buffer.from(keyText, 'utf8').toString('base64'), 'utf8')
|
|
).toString('base64');
|
|
return { body, encryptedKey };
|
|
}
|
|
|
|
function decryptResponse(text, encryptedKey) {
|
|
if (!encryptedKey) return JSON.parse(text);
|
|
const keyBase64 = crypto.privateDecrypt(
|
|
{ key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
|
|
Buffer.from(encryptedKey, 'base64')
|
|
).toString('utf8');
|
|
const decipher = crypto.createDecipheriv('aes-256-ecb', Buffer.from(keyBase64, 'base64'), null);
|
|
decipher.setAutoPadding(true);
|
|
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString('utf8'));
|
|
}
|
|
|
|
async function request(method, endpoint, body, { encrypted = false, clientId = adminClientId } = {}) {
|
|
const headers = { clientid: clientId, 'Content-Language': 'zh_CN' };
|
|
let payload;
|
|
if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json;charset=utf-8';
|
|
if (encrypted) {
|
|
const value = encryptPayload(body);
|
|
headers['encrypt-key'] = value.encryptedKey;
|
|
payload = value.body;
|
|
} else {
|
|
payload = JSON.stringify(body);
|
|
}
|
|
}
|
|
const response = await fetch(`${baseUrl}${endpoint}`, {
|
|
method,
|
|
headers,
|
|
body: payload,
|
|
signal: AbortSignal.timeout(20_000)
|
|
});
|
|
const text = await response.text();
|
|
return encrypted ? decryptResponse(text, response.headers.get('encrypt-key')) : JSON.parse(text);
|
|
}
|
|
|
|
function docker(container, command) {
|
|
return execFileSync('docker', ['exec', container, ...command], { encoding: 'utf8' }).trim();
|
|
}
|
|
|
|
function mysql(sql) {
|
|
return docker('wygj-mysql', ['mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql]);
|
|
}
|
|
|
|
async function adminLogin() {
|
|
const captcha = await request('GET', '/auth/code');
|
|
const uuid = captcha.data?.uuid;
|
|
if (!uuid) throw new Error('本地管理员登录未返回验证码UUID');
|
|
const redis = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--raw', 'get', `global:captcha_codes:${uuid}`]);
|
|
const code = redis.split('\n').pop().replace(/^"|"$/g, '');
|
|
const response = await request('POST', '/auth/login', {
|
|
tenantId: '000000',
|
|
username: process.env.AIHR_VERIFY_USER || 'admin',
|
|
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
|
|
rememberMe: false,
|
|
code,
|
|
uuid,
|
|
clientId: adminClientId,
|
|
grantType: 'password'
|
|
}, { encrypted: true });
|
|
if (!response.data?.access_token) throw new Error(`本地管理员登录失败: ${response.msg || response.code}`);
|
|
return response.data.access_token;
|
|
}
|
|
|
|
async function mobileLogin(phonenumber) {
|
|
const mobileClientId = '428a8310cd442757ae699df5d894f051';
|
|
const sent = await request('GET', `/resource/sms/code?phonenumber=${encodeURIComponent(phonenumber)}`,
|
|
undefined, { clientId: mobileClientId });
|
|
if (Number(sent.code) !== 200) throw new Error(`本地手机号 ${phonenumber} 验证码准备失败: ${sent.msg || sent.code}`);
|
|
const response = await request('POST', '/auth/mobile/sms-login', {
|
|
phonenumber,
|
|
smsCode: process.env.AIHR_VERIFY_SMS_CODE || '123456',
|
|
tenantId: '000000'
|
|
}, { clientId: mobileClientId });
|
|
if (!response.data?.access_token) throw new Error(`本地手机号 ${phonenumber} 登录失败: ${response.msg || response.code}`);
|
|
return response.data.access_token;
|
|
}
|
|
|
|
async function prepareSharedDocumentFixture(adminToken, fileName, marker) {
|
|
const form = new FormData();
|
|
form.append('file', new Blob([`${marker}\n员工应遵守基本制度,包括信息保密、考勤和服务规范。\n报修受理后第一步应登记业主信息、报修事项和联系方式并生成工单。\n该文件仅用于验证同一OSS在两个知识空间中的独立成员关系。\n`],
|
|
{ type: 'text/plain;charset=utf-8' }), fileName);
|
|
form.append('spaceCodes', 'yc_public_policy');
|
|
form.append('spaceCodes', 'yc_property_sop');
|
|
const response = await fetch(`${baseUrl}/api/knowledge/doc/upload`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${adminToken}`, clientid: adminClientId },
|
|
body: form,
|
|
signal: AbortSignal.timeout(60_000)
|
|
});
|
|
const payload = await response.json();
|
|
if (Number(payload.code) !== 200) throw new Error(`双空间测试文档准备失败: ${payload.msg || payload.code}`);
|
|
console.log('PASS 双空间测试文档已准备');
|
|
}
|
|
|
|
function installTemporaryMeituToken() {
|
|
const token = `ak_mt_card_miniapp_${crypto.randomBytes(32).toString('base64url')}`;
|
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
|
mysql(`update aihr_knowledge_app set token_hash='${tokenHash}', status='ACTIVE', rate_limit_per_minute=10, expires_time=now()+interval 1 day where tenant_id='100001' and app_code='mt_card_miniapp' and auth_type='API_TOKEN'`);
|
|
return token;
|
|
}
|
|
|
|
function disableTemporaryMeituToken() {
|
|
mysql("update aihr_knowledge_app set token_hash=null, status='DISABLED', rate_limit_per_minute=60, expires_time=null where tenant_id='100001' and app_code='mt_card_miniapp'");
|
|
}
|
|
|
|
function clearLocalAppRateLimit() {
|
|
const output = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--scan', '--pattern', '*knowledge:app:*']);
|
|
for (const key of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
|
|
docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', 'del', key]);
|
|
}
|
|
}
|
|
|
|
function clearLocalMobileAuthFixture() {
|
|
const output = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--scan', '--pattern', '*1390000000*']);
|
|
for (const key of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
|
|
docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', 'del', key]);
|
|
}
|
|
}
|
|
|
|
async function expectExternalRejected(token, label) {
|
|
const response = await fetch(`${baseUrl}/api/open/knowledge/query`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ queryText: '认证状态验证', spaceCodes: ['mt_customer_service'] }),
|
|
signal: AbortSignal.timeout(20_000)
|
|
});
|
|
const payload = await response.json();
|
|
if (Number(payload.code) !== 401) throw new Error(`${label}: 期望业务码401,实际${payload.code}`);
|
|
console.log(`PASS ${label}`);
|
|
}
|
|
|
|
async function main() {
|
|
const externalToken = installTemporaryMeituToken();
|
|
clearLocalAppRateLimit();
|
|
clearLocalMobileAuthFixture();
|
|
try {
|
|
const [adminToken, employeeToken, supervisorToken] = await Promise.all([
|
|
adminLogin(), mobileLogin('13900000001'), mobileLogin('13900000002')
|
|
]);
|
|
const sharedDocumentName = process.env.AIHR_SHARED_DOC_NAME || 'knowledge-platform-shared-verification.txt';
|
|
const sharedDocumentQuery = process.env.AIHR_SHARED_DOC_QUERY || '知识空间共享验证标记';
|
|
if (process.env.AIHR_VERIFY_UNBIND === 'true') {
|
|
await prepareSharedDocumentFixture(adminToken, sharedDocumentName, sharedDocumentQuery);
|
|
}
|
|
const results = await verifyKnowledgePlatform({
|
|
AIHR_BASE_URL: baseUrl,
|
|
AIHR_SILVER_ADMIN_TOKEN: adminToken,
|
|
AIHR_SILVER_EMPLOYEE_TOKEN: employeeToken,
|
|
AIHR_SILVER_SUPERVISOR_TOKEN: supervisorToken,
|
|
AIHR_MEITU_APP_TOKEN: externalToken,
|
|
AIHR_SHARED_DOC_QUERY: sharedDocumentQuery,
|
|
AIHR_MEITU_QUERY: process.env.AIHR_MEITU_QUERY || 'MEITU_VERIFY_20260716',
|
|
AIHR_MEITU_RATE_LIMIT: 10,
|
|
AIHR_VERIFY_RATE_LIMIT: true,
|
|
AIHR_VERIFY_UNBIND: process.env.AIHR_VERIFY_UNBIND === 'true',
|
|
AIHR_SHARED_DOC_NAME: sharedDocumentName,
|
|
AIHR_VERIFY_TIMEOUT_MS: 20_000
|
|
});
|
|
|
|
mysql("update aihr_knowledge_app set status='DISABLED' where tenant_id='100001' and app_code='mt_card_miniapp'");
|
|
await expectExternalRejected(externalToken, '停用应用令牌被拒绝');
|
|
mysql("update aihr_knowledge_app set status='ACTIVE', expires_time=now()-interval 1 minute where tenant_id='100001' and app_code='mt_card_miniapp'");
|
|
await expectExternalRejected(externalToken, '过期应用令牌被拒绝');
|
|
console.log(`LOCAL_HTTP_RESULT ${results.length + 2}/${results.length + 2}`);
|
|
} finally {
|
|
disableTemporaryMeituToken();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`本地知识平台验证失败: ${error.message}`);
|
|
process.exitCode = 1;
|
|
});
|