#!/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); }