241 lines
9.7 KiB
JavaScript
241 lines
9.7 KiB
JavaScript
#!/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';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const baseUrl = process.env.AIHR_BASE_URL || 'https://wygj-api.localhost';
|
|
const env = readEnv(path.join(root, 'frontend/.env.development'));
|
|
const clientId = env.VITE_APP_CLIENT_ID;
|
|
const requestPublicKey = pem('PUBLIC KEY', env.VITE_APP_RSA_PUBLIC_KEY);
|
|
const responsePrivateKey = pem('PRIVATE KEY', env.VITE_APP_RSA_PRIVATE_KEY);
|
|
const marker = `CALIBRATION_E2E_${Date.now()}`;
|
|
|
|
if (new URL(baseUrl).hostname.endsWith('.localhost')) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
|
|
const created = { ruleId: 0, datasetId: 0 };
|
|
let token = '';
|
|
|
|
try {
|
|
token = await login();
|
|
const before = governanceCounts();
|
|
|
|
const rule = await ok('create rule', 'POST', '/api/knowledge/quality/rules', {
|
|
ruleCode: marker,
|
|
stage: 'QUALITY',
|
|
riskClass: 'HIGH',
|
|
action: 'QUARANTINE',
|
|
implementationType: 'DETERMINISTIC',
|
|
scope: { sourceTypes: ['UPLOAD'] },
|
|
config: { reasonCode: 'SOURCE_UNKNOWN' }
|
|
});
|
|
created.ruleId = rule.id;
|
|
await ok('enter shadow', 'POST', `/api/knowledge/quality/rules/${rule.id}/status`, {
|
|
targetStatus: 'SHADOW',
|
|
reason: 'local calibration verification'
|
|
});
|
|
|
|
const dataset = await ok('create golden dataset', 'POST', '/api/knowledge/quality/golden-datasets', {
|
|
datasetCode: marker,
|
|
name: 'Local calibration verification',
|
|
description: 'Temporary human label used by the local verification script'
|
|
});
|
|
created.datasetId = dataset.id;
|
|
const sample = await ok('add golden sample', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/samples`, {
|
|
sampleKey: 'unknown-source-policy',
|
|
riskClass: 'HIGH',
|
|
expectedDecision: 'BLOCK',
|
|
reasonCodes: ['SOURCE_UNKNOWN'],
|
|
evidenceRef: 'local-verification:human-reviewed-source-policy'
|
|
});
|
|
const frozen = await ok('freeze golden dataset', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/freeze`);
|
|
assert(frozen.status === 'FROZEN' && /^[a-f0-9]{64}$/.test(frozen.contentHash), 'frozen dataset hash missing');
|
|
await rejected('frozen dataset mutation', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/samples`, {
|
|
sampleKey: 'late-mutation',
|
|
riskClass: 'LOW',
|
|
expectedDecision: 'PASS',
|
|
reasonCodes: [],
|
|
evidenceRef: 'must be rejected'
|
|
});
|
|
|
|
const evaluation = await ok('golden evaluation', 'POST', `/api/knowledge/quality/rules/${rule.id}/evaluations`, {
|
|
sampleKey: 'client-supplied-key-must-be-ignored',
|
|
expectedDecision: 'PASS',
|
|
actualDecision: 'PASS',
|
|
confidence: 0.99,
|
|
expectedSource: 'GOLDEN',
|
|
matchedReasonCodes: [],
|
|
runId: `${marker}:shadow`,
|
|
goldenSampleId: sample.id
|
|
});
|
|
assert(evaluation.expectedDecision === 'BLOCK', 'client overrode the frozen golden label');
|
|
assert(evaluation.falseAllow === true, 'server did not derive false-allow from the frozen label');
|
|
assert(evaluation.goldenSampleId === sample.id, 'golden evaluation lineage is missing');
|
|
|
|
await rejected('unsafe threshold floor', 'POST', `/api/knowledge/quality/rules/${rule.id}/acceptance-profiles`, {
|
|
minTotalSamples: 1,
|
|
minGoldenSamples: 1,
|
|
minReviewedSamples: 1,
|
|
minAgreementRate: 0.5,
|
|
maxFalseAllowRate: 0.5,
|
|
maxFalseBlockRate: 0.5,
|
|
minReviewCoverageRate: 0
|
|
});
|
|
const profile = await ok('create acceptance profile', 'POST', `/api/knowledge/quality/rules/${rule.id}/acceptance-profiles`, {
|
|
minTotalSamples: 100,
|
|
minGoldenSamples: 50,
|
|
minReviewedSamples: 30,
|
|
minAgreementRate: 0.99,
|
|
maxFalseAllowRate: 0,
|
|
maxFalseBlockRate: 0.02,
|
|
minReviewCoverageRate: 0.5
|
|
});
|
|
await ok('freeze acceptance profile', 'POST',
|
|
`/api/knowledge/quality/rules/${rule.id}/acceptance-profiles/${profile.id}/freeze`,
|
|
{ reason: 'local risk-threshold verification' });
|
|
const readiness = await ok('readiness', 'GET', `/api/knowledge/quality/rules/${rule.id}/readiness`);
|
|
assert(readiness.evidenceReady === false, 'insufficient calibration evidence was marked ready');
|
|
assert(readiness.enforcementEnabled === false, 'automatic enforcement was enabled');
|
|
assert(readiness.reasonCodes.includes('TOTAL_SAMPLE_INSUFFICIENT'), 'missing total-sample failure reason');
|
|
assert(readiness.reasonCodes.includes('FALSE_ALLOW_ABOVE_THRESHOLD'), 'missing false-allow failure reason');
|
|
await rejected('active transition', 'POST', `/api/knowledge/quality/rules/${rule.id}/status`, {
|
|
targetStatus: 'ACTIVE',
|
|
reason: 'must remain unavailable'
|
|
});
|
|
|
|
const after = governanceCounts();
|
|
assert(before.assets === after.assets, 'asset count changed during calibration verification');
|
|
assert(before.outbox === after.outbox, 'index outbox changed during calibration verification');
|
|
console.log(JSON.stringify({
|
|
passed: true,
|
|
frozenHash: true,
|
|
serverDerivedGoldenLabel: true,
|
|
unsafeThresholdRejected: true,
|
|
enforcementEnabled: readiness.enforcementEnabled,
|
|
readinessReasonCodes: readiness.reasonCodes,
|
|
assetMutationCount: after.assets - before.assets,
|
|
indexMutationCount: after.outbox - before.outbox
|
|
}, null, 2));
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
|
|
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 login() {
|
|
const encrypted = encryptPayload({
|
|
tenantId: '000000',
|
|
username: process.env.AIHR_VERIFY_USER || 'admin',
|
|
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
|
|
rememberMe: false,
|
|
clientId,
|
|
grantType: 'password'
|
|
});
|
|
const response = await fetch(`${baseUrl}/auth/login`, {
|
|
method: 'POST',
|
|
headers: { clientid: clientId, 'Content-Type': 'application/json;charset=utf-8', 'encrypt-key': encrypted.encryptedKey },
|
|
body: encrypted.body,
|
|
signal: AbortSignal.timeout(20_000)
|
|
});
|
|
const body = decryptResponse(await response.text(), response.headers.get('encrypt-key'));
|
|
if (!body.data?.access_token) throw new Error(`admin login failed: ${body.msg || body.code}`);
|
|
return body.data.access_token;
|
|
}
|
|
|
|
async function api(method, endpoint, body) {
|
|
const response = await fetch(`${baseUrl}${endpoint}`, {
|
|
method,
|
|
headers: {
|
|
clientid: clientId,
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Language': 'zh_CN',
|
|
...(body === undefined ? {} : { 'Content-Type': 'application/json;charset=utf-8' })
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: AbortSignal.timeout(20_000)
|
|
});
|
|
return JSON.parse(await response.text());
|
|
}
|
|
|
|
async function ok(label, method, endpoint, body) {
|
|
const response = await api(method, endpoint, body);
|
|
if (Number(response.code) !== 200) throw new Error(`${label} failed: ${response.msg || response.code}`);
|
|
return response.data;
|
|
}
|
|
|
|
async function rejected(label, method, endpoint, body) {
|
|
const response = await api(method, endpoint, body);
|
|
if (Number(response.code) === 200) throw new Error(`${label} unexpectedly succeeded`);
|
|
}
|
|
|
|
function mysql(sql) {
|
|
return execFileSync('docker', [
|
|
'exec', 'wygj-mysql', 'mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql
|
|
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
}
|
|
|
|
function governanceCounts() {
|
|
const values = mysql("select (select count(*) from aihr_data_asset), (select count(*) from aihr_index_outbox)")
|
|
.split(/\s+/).map(Number);
|
|
return { assets: values[0], outbox: values[1] };
|
|
}
|
|
|
|
function cleanup() {
|
|
if (!created.ruleId && !created.datasetId) return;
|
|
const ruleId = Number(created.ruleId) || 0;
|
|
const datasetId = Number(created.datasetId) || 0;
|
|
mysql(`
|
|
delete link from aihr_rule_golden_evaluation link
|
|
join aihr_rule_evaluation evaluation on evaluation.id = link.evaluation_id
|
|
where evaluation.rule_id = ${ruleId};
|
|
delete from aihr_review_sample where rule_id = ${ruleId};
|
|
delete from aihr_rule_evaluation where rule_id = ${ruleId};
|
|
delete from aihr_rule_acceptance_profile where rule_id = ${ruleId};
|
|
delete from aihr_processing_rule_transition where rule_id = ${ruleId};
|
|
delete from aihr_processing_rule where id = ${ruleId};
|
|
delete from aihr_golden_sample where dataset_id = ${datasetId};
|
|
delete from aihr_golden_dataset where id = ${datasetId};
|
|
`);
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) throw new Error(message);
|
|
}
|