feat(test): continue reviewed return source sample with pre-signature audit

This commit is contained in:
2026-10-03 22:13:15 +08:00
parent 6dbc899626
commit 0c8ede3889
5 changed files with 346 additions and 17 deletions
@@ -1,6 +1,10 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { selectReturnSources, verifyFleetReturnRedispatch } from './fleet-return-redispatch-acceptance.mjs'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { createHash } from 'node:crypto'
import { selectReturnSources, verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit } from './fleet-return-redispatch-acceptance.mjs'
function fixture(fault = '') {
const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = []
@@ -47,6 +51,11 @@ function fixture(fault = '') {
throw Error(`Unexpected ${method} ${path}`)
} }
const dependencies = {
async onBeforeRedispatchSignature(proof) {
assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING')
if (fault === 'audit-gate') throw Error('Audit gate not approved')
return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) }
},
async prepareFleetTrip() { return { tripNo: 'TR-NEW' } }, async confirmFleetLoading() { return 1 }, async pickAll() { return true },
async finishFleetTrip() { return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } },
async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } },
@@ -56,6 +65,24 @@ function fixture(fault = '') {
}
const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies)
test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => {
const f = fixture(); f.input.sourceSelectionRecovery = true
assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
assert.ok(!f.requests.some(row => row.path?.endsWith('/status?customerId=C')))
})
test('reviewed source recovery refuses WAIT_DELIVERY before any delivery-start or return command', async () => {
const f = fixture(); f.input.sourceSelectionRecovery = true
const request = f.context.request
f.context.request = async (...args) => {
const r = await request(...args)
if (r.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') r.body.data.order.status = 'WAIT_DELIVERY'
return r
}
await assert.rejects(run(f), /never start it again/)
assert.ok(f.requests.every(row => row.method === 'GET'))
})
test('fresh return/redispatch chain proves A pending, B complete, then A complete without claiming unique source events', async () => {
const f = fixture(), result = await run(f)
assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
@@ -68,6 +95,42 @@ test('fresh return/redispatch chain proves A pending, B complete, then A complet
assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token'))
assert.ok(f.checkpoints.some(row => row.requests.at(-1)?.status === 'SENDING' && row.requests.at(-1)?.file?.sha256))
})
for (const missing of [false, true]) test(`unapproved or missing pre-signature gate cannot sign or close new trip; missing=${missing}`, async () => {
const f = fixture('audit-gate')
if (missing) delete f.dependencies.onBeforeRedispatchSignature
const original = f.dependencies.saveReceiptAndComplete
f.dependencies.saveReceiptAndComplete = async (...args) => { assert.notEqual(args[1].deliveryNo, 'DL-NEW'); return original(...args) }
f.dependencies.finishFleetTrip = () => assert.fail('new trip must not close before audited signature')
await assert.rejects(run(f), missing ? /audit gate is required/ : /Audit gate not approved/)
assert.equal(f.trip.returnRedispatch.preSignatureGate.status, 'WAITING')
assert.equal(f.trip.returnRedispatch.afterRedispatch, undefined)
})
for (const fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test(`file gate requires exact checkpoint and hashed source audit: ${fault}`, async () => {
const root = mkdtempSync(join(tmpdir(), 'fleet-signature-gate-')), output = join(root, 'reports', 'run')
mkdirSync(output, { recursive: true })
const context = { marker: 'CURRENT', context: { fleetRunId: 'FRESH' }, createdResources: { fleetAcceptance: { tripNo: 'TR-ORIGINAL', customerId: 'C' } } }
const proof = { newTripNo: 'TR-NEW', sources: { A: { tail: { sourceInboundId: 'A' } }, B: { tail: { sourceInboundId: 'B' } } } }
try {
const wait = async () => {
const checkpoint = JSON.parse(readFileSync(join(output, 'before-redispatch-signature.json'), 'utf8'))
assert.equal(checkpoint.proof.newTripNo, 'TR-NEW'); assert.equal(checkpoint.sourceAInboundId, 'A')
const evidence = Buffer.from('{"A":0,"B":1}')
writeFileSync(join(root, 'reports', 'audit.json'), evidence)
writeFileSync(join(output, 'approve-before-redispatch-signature.json'), JSON.stringify({ gateNonce: fault === 'nonce' ? 'OTHER' : checkpoint.gateNonce,
marker: checkpoint.marker, newTripNo: fault === 'trip' ? 'OTHER' : checkpoint.newTripNo,
sourceAEventCount: fault === 'counts' ? 1 : 0, sourceBEventCount: 1, auditEvidencePath: 'reports/audit.json',
auditEvidenceSha256: fault === 'hash' ? 'a'.repeat(64) : createHash('sha256').update(evidence).digest('hex') }))
}
const promise = waitForRedispatchSignatureAudit(context, proof, { root, output, wait, timeoutMs: fault === 'timeout' ? 0 : 5000 })
if (fault === 'none') { const result = await promise; assert.equal(result.sourceAEventCount, 0); assert.equal(result.sourceBEventCount, 1) }
else await assert.rejects(promise, /gate|approval|SHA256/)
} finally {
assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(resolve(root).startsWith(join(resolve(tmpdir()), 'fleet-signature-gate-')))
rmSync(root, { recursive: true, force: true })
}
})
for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'wrong-candidate', 'rewritten-original']) {
test(`rejects ${fault} instead of declaring redispatch complete`, async () => {
const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED')
@@ -1,3 +1,6 @@
import {existsSync,openSync,writeFileSync,fsyncSync,closeSync,readFileSync} from 'node:fs'
import {join,resolve} from 'node:path'
import {redactEvidence} from '../fba-core-regression-test-env.mjs'
import { randomUUID, createHash } from 'node:crypto'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs'
@@ -30,7 +33,7 @@ export function selectReturnSources(rows) {
return { a, b }
}
/** Fresh-run-only stage. Every helper HTTP request is checkpointed before dispatch, including multipart metadata. */
/** Owned departure only; reviewed source-selection recovery preserves prior starts. Requests are checkpointed before dispatch. */
export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) {
const run = context.createdResources?.fleetAcceptance
if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker
@@ -71,6 +74,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data
}
const deps = { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, pickAll, saveReceiptAndComplete,
onBeforeRedispatchSignature: context.context?.onBeforeRedispatchSignature,
wait: ms => new Promise(resolve => setTimeout(resolve, ms)), ...dependencies }
const customerId = id(input.customerId), warehouseId = id(input.warehouseId)
const tail = relation => request('GET', scoped(`${BASE}/orders/${relation.lastMileOrderId}`, customerId))
@@ -98,6 +102,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
const current = await delivery(number)
check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo))
proof.originalDeliveries.push(copy(current.order))
if (input.sourceSelectionRecovery) check('Reviewed source recovery must retain the already started delivery; never start it again', current.order.status === 'DELIVERING')
if (current.order.status === 'WAIT_DELIVERY') {
check('Delivery status mutation requires the current version', Number.isSafeInteger(Number(current.order.version))
&& current.order.version != null && String(current.order.version).trim() !== '' && Number(current.order.version) >= 0)
@@ -186,6 +191,15 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
const departedAgain = await request('POST', departPath, departBody, input.pickerToken)
check('Departure replay does not duplicate shipping', departedAgain.replayed === true && Number(departedAgain.shippedPackages) === 2)
const newDelivery = await delivery(transfer.deliveries[0].deliveryNo)
proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo,
departed, originalSignatureCount: proof.originalSignatures.length }
await context.checkpoint()
check('Readonly source event audit gate is required before redispatch signature', typeof deps.onBeforeRedispatchSignature === 'function')
const audit = await deps.onBeforeRedispatchSignature(proof)
check('Pre-signature audit proves A has zero completion events and B has one', audit?.sourceAEventCount === 0
&& audit.sourceBEventCount === 1 && /^[a-f0-9]{64}$/.test(audit.auditEvidenceSha256 || ''))
Object.assign(proof.preSignatureGate, { status: 'VERIFIED', audit })
await context.checkpoint()
const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271)
check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && Number(newSignature.order.signedPackages) === 2)
const afterA = await tail(a.relation), afterB = await tail(b.relation)
@@ -202,3 +216,37 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
await context.checkpoint(); return proof
} catch (error) { proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error }
}
export async function waitForRedispatchSignatureAudit(context, proof, { root, output, secrets = [],
wait = ms => new Promise(resolve => setTimeout(resolve, ms)), timeoutMs = 20 * 60 * 1000 } = {}) {
const gateNonce = randomUUID(), approvalPath = join(output, 'approve-before-redispatch-signature.json')
const checkpointPath = join(output, 'before-redispatch-signature.json')
const checkpoint = { gateNonce, marker: context.marker, runId: context.context.fleetRunId,
originalTripNo: context.createdResources.fleetAcceptance.tripNo, newTripNo: proof.newTripNo,
sourceAInboundId: proof.sources.A.tail.sourceInboundId, sourceBInboundId: proof.sources.B.tail.sourceInboundId,
customerId: context.createdResources.fleetAcceptance.customerId, proof, createdResources: context.createdResources }
const descriptor = openSync(checkpointPath, 'wx')
try { writeFileSync(descriptor, JSON.stringify(redactEvidence(checkpoint, secrets), null, 2) + '\n'); fsyncSync(descriptor) }
finally { closeSync(descriptor) }
const progressDescriptor = openSync(join(output, 'progress.json'), 'w')
try {
writeFileSync(progressDescriptor, JSON.stringify(redactEvidence({ stage: 'RETURN_REDISPATCH_PRE_SIGNATURE', marker: context.marker,
steps: context.steps || [], modules: context.modules || [], createdResources: context.createdResources }, secrets), null, 2) + '\n')
fsyncSync(progressDescriptor)
} finally { closeSync(progressDescriptor) }
console.log('FLEET_SIGNATURE_AUDIT_WAITING=' + JSON.stringify({ checkpointPath, approvalPath, gateNonce, marker: context.marker }))
const started = Date.now()
while (!existsSync(approvalPath)) {
if (Date.now() - started >= timeoutMs) throw Error('Pre-signature audit gate timed out; do not sign or resume automatically')
await wait(1000)
}
const approval = JSON.parse(readFileSync(approvalPath, 'utf8'))
if (approval.gateNonce !== gateNonce || approval.marker !== checkpoint.marker || approval.newTripNo !== checkpoint.newTripNo
|| approval.sourceAEventCount !== 0 || approval.sourceBEventCount !== 1 || !/^[a-f0-9]{64}$/.test(approval.auditEvidenceSha256 || '')
|| !/^reports\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)?\.json$/.test(approval.auditEvidencePath || ''))
throw Error('Pre-signature approval does not match this exact gate and source counts')
const evidencePath = resolve(root, approval.auditEvidencePath)
if (!existsSync(evidencePath) || createHash('sha256').update(readFileSync(evidencePath)).digest('hex') !== approval.auditEvidenceSha256)
throw Error('Pre-signature audit evidence SHA256 mismatch')
return approval
}