feat(test): continue reviewed return source sample with pre-signature audit
This commit is contained in:
+64
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user