183 lines
6.8 KiB
JavaScript
183 lines
6.8 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { createHash } from 'node:crypto'
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { spawnSync } from 'node:child_process'
|
|
import test from 'node:test'
|
|
|
|
const testDir = path.dirname(fileURLToPath(import.meta.url))
|
|
const projectRoot = path.resolve(testDir, '..', '..')
|
|
const verifierPath = path.join(projectRoot, 'scripts', 'verify-aug1-formal-content.mjs')
|
|
const pendingApprovalPath = path.join(
|
|
projectRoot,
|
|
'docs',
|
|
'content-candidates',
|
|
'aug1-release-approval.json',
|
|
)
|
|
const candidatePath = path.join(
|
|
projectRoot,
|
|
'docs',
|
|
'content-candidates',
|
|
'aug1-life-advisor-content-candidates-v0.1.json',
|
|
)
|
|
const candidateBytes = await readFile(candidatePath)
|
|
const candidate = JSON.parse(candidateBytes.toString('utf8'))
|
|
const candidateSha256 = createHash('sha256').update(candidateBytes).digest('hex')
|
|
|
|
const source = {
|
|
sourceId: 'FORMAL-OPS-001',
|
|
title: '现行生活顾问服务作业标准',
|
|
version: '2026.07',
|
|
effectiveDate: '2026-07-01',
|
|
scope: '受控内测项目',
|
|
sha256: 'a'.repeat(64),
|
|
formalPolicy: true,
|
|
}
|
|
|
|
function validApproval() {
|
|
const scenarioApprovals = Object.fromEntries(candidate.scenarios.map((item, index) => {
|
|
const highRisk = item.proposedRiskLevel === '高风险'
|
|
return [item.candidateId, {
|
|
status: 'APPROVED',
|
|
scenarioCode: item.scenarioDraft.id,
|
|
riskLevel: item.proposedRiskLevel,
|
|
sourceRefs: ['FORMAL-OPS-001:section-1'],
|
|
reviewedBy: `reviewer-${index + 1}`,
|
|
reviewedAt: '2026-07-30T09:00:00+08:00',
|
|
...(highRisk ? {
|
|
secondReviewedBy: `second-reviewer-${index + 1}`,
|
|
secondReviewedAt: '2026-07-30T10:00:00+08:00',
|
|
} : {}),
|
|
businessEvidenceRef: `evidence/scenario-${index + 1}.pdf`,
|
|
productionContentVersion: `aug1-v${index + 1}`,
|
|
productionContentHash: String(index + 1).repeat(64),
|
|
}]
|
|
}))
|
|
const policyQuestionApprovals = Object.fromEntries(candidate.policyQuestionCandidates.map((item, index) => {
|
|
const answerDisposition = item.category === 'NO_EVIDENCE'
|
|
? 'FORMAL_NO_EVIDENCE_BOUNDARY'
|
|
: item.category === 'UNAUTHORIZED'
|
|
? 'FORMAL_UNAUTHORIZED_BOUNDARY'
|
|
: 'FORMALLY_SOURCED'
|
|
return [item.id, {
|
|
status: 'APPROVED',
|
|
answerDisposition,
|
|
sourceRefs: ['FORMAL-OPS-001:section-2'],
|
|
expectedBehavior: item.expectedBehavior,
|
|
observedBehavior: '验收结果与预期边界一致',
|
|
evidenceRef: `evidence/question-${index + 1}.json`,
|
|
evidenceSha256: 'b'.repeat(64),
|
|
reviewedBy: `question-reviewer-${index + 1}`,
|
|
reviewedAt: '2026-07-30T11:00:00+08:00',
|
|
}]
|
|
}))
|
|
const signoffs = Object.fromEntries([
|
|
'businessOwner',
|
|
'knowledgeOwner',
|
|
'trainingOwner',
|
|
'channelOwner',
|
|
'releaseOwner',
|
|
].map((role) => [role, {
|
|
status: 'APPROVED',
|
|
reviewedBy: `${role}-reviewer`,
|
|
reviewedAt: '2026-07-30T12:00:00+08:00',
|
|
}]))
|
|
return {
|
|
schemaVersion: '1.0',
|
|
releaseTarget: '2026-08-01',
|
|
tenantId: '000000',
|
|
candidateFile: 'aug1-life-advisor-content-candidates-v0.1.json',
|
|
candidateSha256,
|
|
releaseStatus: 'APPROVED',
|
|
formalContentVersion: 'aug1-formal-v1',
|
|
sourceRegistry: [source],
|
|
scenarioApprovals,
|
|
policyQuestionApprovals,
|
|
signoffs,
|
|
}
|
|
}
|
|
|
|
async function verifyApproval(approval, ...options) {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'aug1-formal-'))
|
|
const tempCandidatePath = path.join(tempDir, 'aug1-life-advisor-content-candidates-v0.1.json')
|
|
const tempApprovalPath = path.join(tempDir, 'approval.json')
|
|
try {
|
|
await writeFile(tempCandidatePath, candidateBytes)
|
|
await writeFile(tempApprovalPath, JSON.stringify(approval), 'utf8')
|
|
return spawnSync(process.execPath, [verifierPath, ...options, tempApprovalPath], {
|
|
cwd: projectRoot,
|
|
encoding: 'utf8',
|
|
})
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
test('audits the checked-in pending manifest without claiming release readiness', () => {
|
|
const audit = spawnSync(process.execPath, [verifierPath, pendingApprovalPath], {
|
|
cwd: projectRoot,
|
|
encoding: 'utf8',
|
|
})
|
|
assert.equal(audit.status, 0, audit.stderr)
|
|
assert.match(audit.stdout, /approved scenarios: 0\/5/)
|
|
assert.match(audit.stdout, /approved policy questions: 0\/30/)
|
|
|
|
const strict = spawnSync(process.execPath, [verifierPath, '--strict', pendingApprovalPath], {
|
|
cwd: projectRoot,
|
|
encoding: 'utf8',
|
|
})
|
|
assert.notEqual(strict.status, 0)
|
|
assert.match(strict.stderr, /releaseStatus must be APPROVED/)
|
|
})
|
|
|
|
test('accepts complete formal evidence and emits five immutable production snapshots', async () => {
|
|
const strict = await verifyApproval(validApproval(), '--strict')
|
|
assert.equal(strict.status, 0, strict.stderr)
|
|
assert.match(strict.stdout, /strict release ready: true/)
|
|
|
|
const snapshots = await verifyApproval(validApproval(), '--scenario-snapshots')
|
|
assert.equal(snapshots.status, 0, snapshots.stderr)
|
|
const lines = snapshots.stdout.trim().split(/\r?\n/)
|
|
assert.equal(lines.length, 5)
|
|
assert.match(lines[0], /^[a-z0-9-]+\|aug1-v1\|1{64}$/)
|
|
})
|
|
|
|
test('rejects incomplete or contradictory formal approvals', async (t) => {
|
|
const cases = [
|
|
['missing question', (approval) => {
|
|
delete approval.policyQuestionApprovals[candidate.policyQuestionCandidates[0].id]
|
|
}, /question approvals must cover exactly 30\/30/],
|
|
['wrong no-evidence behavior', (approval) => {
|
|
const item = candidate.policyQuestionCandidates.find((question) => question.category === 'NO_EVIDENCE')
|
|
approval.policyQuestionApprovals[item.id].answerDisposition = 'FORMALLY_SOURCED'
|
|
}, /must preserve the formal refusal boundary/],
|
|
['same high-risk reviewer', (approval) => {
|
|
const item = candidate.scenarios.find((scenario) => scenario.proposedRiskLevel === '高风险')
|
|
approval.scenarioApprovals[item.candidateId].secondReviewedBy =
|
|
approval.scenarioApprovals[item.candidateId].reviewedBy
|
|
}, /high-risk reviewers must be different people/],
|
|
['unknown source', (approval) => {
|
|
const item = candidate.scenarios[0]
|
|
approval.scenarioApprovals[item.candidateId].sourceRefs = ['MISSING-SOURCE:section-1']
|
|
}, /is not in sourceRegistry/],
|
|
['placeholder source version', (approval) => {
|
|
approval.sourceRegistry[0].version = '待填写'
|
|
}, /formal source version is required/],
|
|
['missing owner sign-off', (approval) => {
|
|
approval.signoffs.releaseOwner.status = 'PENDING'
|
|
}, /releaseOwner: sign-off must be APPROVED/],
|
|
]
|
|
|
|
for (const [name, mutate, expected] of cases) {
|
|
await t.test(name, async () => {
|
|
const approval = validApproval()
|
|
mutate(approval)
|
|
const result = await verifyApproval(approval, '--strict')
|
|
assert.notEqual(result.status, 0)
|
|
assert.match(result.stderr, expected)
|
|
})
|
|
}
|
|
})
|