test: match non-null fee source hash serialization
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user