diff --git a/scripts/test-event-fault-runtime.py b/scripts/test-event-fault-runtime.py index de8b5c469..e7be87886 100644 --- a/scripts/test-event-fault-runtime.py +++ b/scripts/test-event-fault-runtime.py @@ -259,10 +259,14 @@ def fee_event(scope, action): row = rows[0] payload = json.loads(row['envelopeJson'], parse_float=decimal.Decimal, parse_int=decimal.Decimal)['payload'] row['payloadCanonicalJson'] = fee_canonical(payload) - unsigned = {**payload, 'action': None, 'sourceStatus': 'ACTIVE', 'sourceHash': None} + # The shared NON_NULL ObjectMapper omits these unsigned DTO fields. + unsigned = {key: value for key, value in payload.items() if key not in ('action', 'sourceHash')} + unsigned['sourceStatus'] = 'ACTIVE' row['sourceCanonicalJson'] = fee_canonical(unsigned) - if hashlib.sha256(row['payloadCanonicalJson'].encode()).hexdigest() != row['payloadHash'].removeprefix('sha256:') or payload.get('sourceHash') != 'sha256:' + hashlib.sha256(row['sourceCanonicalJson'].encode()).hexdigest(): + if hashlib.sha256(row['payloadCanonicalJson'].encode()).hexdigest() != row['payloadHash'].removeprefix('sha256:'): raise RuntimeError('FEE_PAYLOAD_HASH_MISMATCH') + if payload.get('sourceHash') != 'sha256:' + hashlib.sha256(row['sourceCanonicalJson'].encode()).hexdigest(): + raise RuntimeError('FEE_SOURCE_HASH_MISMATCH') if row['customerId'] != scope['customerId'] or row['aggregateId'] != scope['sourceFeeId'] or row['aggregateType'] != scope['sourceType']: raise RuntimeError('FEE_EVENT_SCOPE_MISMATCH') if payload.get('sourceSystem') != 'LASTLEG' or payload.get('customerId') != scope['customerId'] or payload.get('sourceFeeId') != scope['sourceFeeId'] or str(payload.get('sourceVersion')) != scope['sourceVersion'] or payload.get('sourceType') != scope['sourceType'] or payload.get('action') != action: diff --git a/scripts/test-fee-void-fault-proof.mjs b/scripts/test-fee-void-fault-proof.mjs index e2f035fe7..78c96a4bc 100644 --- a/scripts/test-fee-void-fault-proof.mjs +++ b/scripts/test-fee-void-fault-proof.mjs @@ -29,9 +29,10 @@ export function assertTestFeeVoidFaultProof(proof,scope) { let parsed,canonical,unsigned try {parsed=JSON.parse(row.envelopeJson);canonical=JSON.parse(row.payloadCanonicalJson);unsigned=JSON.parse(row.sourceCanonicalJson)} catch {need(false,'CANONICAL_JSON')} - need(equal(parsed,env) && equal(canonical,p) && equal(unsigned,{...p,action:null,sourceStatus:'ACTIVE',sourceHash:null}) - && hash(row.payloadCanonicalJson)===row.payloadHash && env.payloadHash===row.payloadHash - && hash(row.sourceCanonicalJson)===p.sourceHash, 'PAYLOAD_HASH') + need(equal(parsed,env) && equal(canonical,p) + && hash(row.payloadCanonicalJson)===row.payloadHash && env.payloadHash===row.payloadHash, 'PAYLOAD_HASH') + need(equal(unsigned,{...without(p,['action','sourceHash']),sourceStatus:'ACTIVE'}) + && hash(row.sourceCanonicalJson)===p.sourceHash, 'SOURCE_HASH') return p } const row=proof.producer,payload=event(row,'VOIDED'),prior=proof.prerequisite,publication=prior?.publication diff --git a/scripts/test-fee-void-fault-proof.test.mjs b/scripts/test-fee-void-fault-proof.test.mjs index 0f6c8b42a..2a3a9e51c 100644 --- a/scripts/test-fee-void-fault-proof.test.mjs +++ b/scripts/test-fee-void-fault-proof.test.mjs @@ -13,12 +13,12 @@ const clone=value=>JSON.parse(JSON.stringify(value)) const imageId='sha256:'+'a'.repeat(64),fingerprint='b'.repeat(64) const restoration={restored:true,healthy:true,proxyStopped:true,producerName:'firstleg-test-lastleg',originalImageId:imageId,currentImageId:imageId,originalFingerprint:fingerprint,currentFingerprint:fingerprint} function fixture() { - const base={action:null,sourceSystem:'LASTLEG',sourceType:scope.sourceType,sourceFeeId:'123',sourceVersion:1,customerId:'7',customerName:'Offline', + const base=Object.fromEntries(Object.entries({action:null,sourceSystem:'LASTLEG',sourceType:scope.sourceType,sourceFeeId:'123',sourceVersion:1,customerId:'7',customerName:'Offline', sourceNo:'WH-OFFLINE',businessType:'WAREHOUSE_SERVICE',businessId:'200',businessNo:'WH-OFFLINE',orderId:null,inboundId:null,inboundCode:null, containerId:null,containerBookingId:null,containerNo:null,warehouseId:'9',feeItemCode:'TEST',feeItemName:'Offline fee',direction:'RECEIVABLE', supplierId:null,supplierName:null,supplierCategory:null,settlementMode:null,paidAt:null,paymentMethod:null,paymentReferenceNo:null,paymentVoucherFileIds:[], businessDate:'2026-10-03',currency:'CNY',totalAmount:7.25,originalCurrency:'CNY',originalAmount:7.25,exchangeRate:1,exchangeRateMonth:'2026-10', - chargeableQuantity:null,quantityUnit:null,unitPrice:null,periodStart:null,periodEnd:null,reviewStatus:'APPROVED',publicationStatus:'PUBLISHED',sourceStatus:'ACTIVE',sourceHash:null} + chargeableQuantity:null,quantityUnit:null,unitPrice:null,periodStart:null,periodEnd:null,reviewStatus:'APPROVED',publicationStatus:'PUBLISHED',sourceStatus:'ACTIVE',sourceHash:null}).filter(([,value])=>value!==null)) const sourceCanonicalJson=text(base),sourceHash=hash(sourceCanonicalJson) const event=(action,index)=>{ const payload={...base,action,sourceStatus:action==='VOIDED'?'VOIDED':'ACTIVE',sourceHash} @@ -119,6 +119,46 @@ test('unknown prepare result is never reissued and cleanup still attempts origin assert.deepEqual(calls,['preflight','prepare','restore','health','finish']) }) +test('NON_NULL source hashes agree across runtime and proof and reject explicit null unsigned fields',()=>{ + const proof=fixture(),publication=proof.prerequisite.publication + const unsigned=JSON.parse(publication.sourceCanonicalJson) + assert.equal(Object.hasOwn(unsigned,'action'),false) + assert.equal(Object.hasOwn(unsigned,'sourceHash'),false) + assert.equal(Object.hasOwn(unsigned,'orderId'),false) + assert.deepEqual(unsigned.paymentVoucherFileIds,[]) + const invalid=clone(proof) + invalid.producer.sourceCanonicalJson=text({...unsigned,action:null,sourceHash:null}) + invalid.producer.envelope.payload.sourceHash=hash(invalid.producer.sourceCanonicalJson) + invalid.producer.payloadCanonicalJson=text(invalid.producer.envelope.payload) + invalid.producer.payloadHash=hash(invalid.producer.payloadCanonicalJson) + invalid.producer.envelope.payloadHash=invalid.producer.payloadHash + invalid.producer.envelopeJson=JSON.stringify(invalid.producer.envelope) + assert.throws(()=>assertTestFeeVoidFaultProof(invalid,scope),/SOURCE_HASH/) + const result=spawnSync('python',['-c',String.raw` +import sys,importlib.util,json,copy,hashlib +from unittest.mock import patch +sys.dont_write_bytecode=True +spec=importlib.util.spec_from_file_location('fault','scripts/test-event-fault-runtime.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +data=json.load(sys.stdin);scope=data['scope'];row=data['row'] +with patch.object(m,'inspect'),patch.object(m,'read_sql',side_effect=lambda *args:[copy.deepcopy(row)]): + result=m.fee_event(scope,'PUBLISHED') + assert result['sourceCanonicalJson']==row['sourceCanonicalJson'] + assert result['payloadCanonicalJson']==row['payloadCanonicalJson'] + payload=json.loads(row['envelopeJson'])['payload'] + unsigned={**payload,'action':None,'sourceHash':None,'sourceStatus':'ACTIVE'} + payload['sourceHash']='sha256:'+hashlib.sha256(m.fee_canonical(unsigned).encode()).hexdigest() + envelope=json.loads(row['envelopeJson']);envelope['payload']=payload + row['envelopeJson']=json.dumps(envelope) + row['payloadHash']='sha256:'+hashlib.sha256(m.fee_canonical(payload).encode()).hexdigest() + try:m.fee_event(scope,'PUBLISHED');assert False + except RuntimeError as e:assert str(e)=='FEE_SOURCE_HASH_MISMATCH' + row['payloadHash']='sha256:'+'0'*64 + try:m.fee_event(scope,'PUBLISHED');assert False + except RuntimeError as e:assert str(e)=='FEE_PAYLOAD_HASH_MISMATCH' +`],{cwd:new URL('..',import.meta.url),input:JSON.stringify({scope,row:publication}),encoding:'utf8',timeout:10000}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) + test('Python FEE_VOID routing, exact decimal hashes, preflight gating and restore reentry are offline',()=>{ const result=spawnSync('python',['-c',String.raw` import sys,importlib.util,pathlib,copy,decimal,ast,io