From 9803dcfd2c980b6a4b12cf4edba14128a48ac0c3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 00:27:45 +0800 Subject: [PATCH] test(order): verify qualification and isolated negative balance gates --- ...ient-test-env-acceptance-contract.test.mjs | 72 ++++++++ scripts/client-test-env-acceptance.mjs | 169 +++++++++++++++++- 2 files changed, 232 insertions(+), 9 deletions(-) diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index a55e5a2d1..bafcebad7 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -28,6 +28,78 @@ function runtime(extra = {}) { } const plain = value => JSON.parse(JSON.stringify(value)) +function balanceGateHarness(fault='') { + const calls=[],ledger=[],report={resources:{tenantId:'20',orderIds:[]},steps:[{module:'registration',name:'enterprise self-registration',passed:true,evidence:{tenantId:'20'}}]} + const wallet={currencyCode:'CNY',version:0,creditActive:false,actualBalance:0,availableBalance:0,frozenBalance:0, + creditLimit:0,creditUsed:0,creditFrozen:0,availableCredit:0} + const detail={order:{id:'42',tenantId:'20',customerId:'20',orderNo:'FL42',status:'DRAFT',version:3},cargoes:[{skuNo:'SKU'}],parties:[],internalNotes:[],statusHistory:[]} + let sequence=0,task=null + if(fault==='other-customer')report.steps[0].evidence.tenantId='21' + if(fault==='funded-account')wallet.actualBalance=1 + const r=runtime({report,marker:'isolated-fixture',merchantToken:'owner',platformToken:'platform',newIdempotencyKey:()=>`key-${++sequence}`, + orderAcceptanceFixture:{payload:{cargoes:[{skuNo:'SKU'}]}}, + apiStep:async(_module,name,request,validate)=>{ + calls.push({name,request:structuredClone(request)}) + let data=null,code='200',message='success' + if(request.path.endsWith('/tenant/profile'))data={id:'20',status:'ACTIVE'} + else if(request.path==='/api/client/finance/v1/account')data=structuredClone(wallet) + else if(request.path.includes('/account/ledger'))data={total:ledger.length,records:structuredClone(ledger)} + else if(request.path.endsWith('/adjustments')) { + assert.equal(request.path,'/api/platform/finance/v1/accounts/20/adjustments') + assert.equal(request.token,'platform');assert.ok(request.headers['Idempotency-Key']) + assert.equal(request.body.amount,0.01);assert.equal(request.body.expectedAccountVersion,wallet.version) + const deduct=request.body.type==='DEDUCTION' + if(deduct&&fault==='deduction-not-applied')throw new Error('not applied') + wallet.actualBalance+=deduct?-0.01:0.01;wallet.availableBalance=wallet.actualBalance;wallet.version++ + ledger.push({sourceType:deduct?'MANUAL_DEDUCTION':'MANUAL_RECHARGE',referenceNo:request.body.referenceNo,amount:deduct?-0.01:0.01}) + if(deduct&&fault==='lost-deduction-response')throw new Error('response lost') + data=ledger.at(-1) + } else if(request.method==='POST'&&request.path==='/api/merchant/v1/orders/first-leg')data='42' + else if(request.path.includes('/submit')) { + assert.equal(wallet.actualBalance,-0.01) + assert.equal(request.body.expectedVersion,3) + assert.ok(request.headers['Idempotency-Key']) + code='A0403';message=fault==='wrong-gate'?'企业未审核':'账户实际余额为负,请充值后再下单' + if(fault==='order-drift')detail.order.version++ + if(fault==='task-drift')task={orderId:'42',tenantId:'20',id:'unexpected'} + } else if(request.path.includes('/tasks/by-order/'))data=structuredClone(task) + else if(request.path.includes('/inbounds?'))data={page:1,total:0,records:[]} + else if(request.path==='/api/merchant/v1/orders/first-leg/42')data=structuredClone(detail) + else throw new Error(`unhandled offline request ${request.path}`) + const response={status:200,json:{code,message,data}};validate(response);return response + }, + }) + return {r,calls,report,wallet,ledger} +} + +test('negative actual funds fixture touches only the new registered customer and restores an audited cent',async()=>{ + const {r,calls,report,wallet,ledger}=balanceGateHarness() + await r.orderSubmissionBalanceGateModule() + assert.equal(wallet.actualBalance,0);assert.equal(wallet.version,2);assert.equal(ledger.length,2) + assert.equal(report.orderSubmissionGates.balance.status,'REJECTIONS_VERIFIED') + assert.equal(report.orderSubmissionGates.balance.restoration,'RESTORED_WITH_AUDIT') + assert.equal(calls.filter(call=>call.request.path.includes('/submit')).length,2) + assert.equal(calls.filter(call=>call.request.path.endsWith('/adjustments')).length,2) + assert.ok(calls.every(call=>!call.request.path.includes('channels')&&!call.request.path.includes('price-books'))) +}) + +for(const fault of ['other-customer','funded-account'])test(`financial fixture refuses unsafe scope: ${fault}`,async()=>{ + const {r,calls}=balanceGateHarness(fault) + await assert.rejects(r.orderSubmissionBalanceGateModule()) + assert.equal(calls.some(call=>call.request.method==='POST'),false) +}) +for(const fault of ['wrong-gate','order-drift','task-drift','lost-deduction-response'])test(`gate evidence fails and restores its proven deduction after ${fault}`,async()=>{ + const {r,wallet,ledger}=balanceGateHarness(fault) + await assert.rejects(r.orderSubmissionBalanceGateModule()) + assert.equal(wallet.actualBalance,0);assert.equal(ledger.length,2) +}) +test('unapplied deduction does not trigger a blind restoring credit',async()=>{ + const {r,wallet,ledger,calls}=balanceGateHarness('deduction-not-applied') + await assert.rejects(r.orderSubmissionBalanceGateModule(),/not applied/) + assert.equal(wallet.actualBalance,0);assert.equal(ledger.length,0) + assert.equal(calls.filter(call=>call.request.path.endsWith('/adjustments')).length,1) +}) + test('frozen edit command preserves original version, key and nested payload through retries', () => { const r = runtime(), snapshot = { order: { version: 7 } }, payload = { draft: { cargoes: [{ skuNo: 'ORIGINAL' }] } } const command = r.orderMutationCommand(snapshot, payload, 'original-key') diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index e28771818..878d9a83d 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -91,6 +91,7 @@ let temporaryOwnerPassword try { await registrationModule() + await platformReviewerLogin() await merchantBeforeApprovalModule() await qualificationAndReviewModule() await merchantAfterApprovalModule() @@ -99,6 +100,7 @@ try { await rbacAndMemberModule() await orderLifecycleModule() await isolationModule() + await orderSubmissionBalanceGateModule() await orderMutationEvidenceModule() await orderExcelImportModule() await notificationInboxModule() @@ -219,13 +221,29 @@ async function merchantBeforeApprovalModule() { assertEqual(String(json.data), String(orderId), 'replayed order id') }, ({ json }) => ({ orderId: json.data, replayed: true })) + const qualificationBefore = await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId) + const qualificationFulfillmentBefore = await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'before qualification rejection') await apiStep('order-before-approval', 'submit is blocked before approval', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, - ...orderMutationCommand(await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId), {}, newIdempotencyKey('pre-approval-submit')), + ...orderMutationCommand(qualificationBefore, {}, newIdempotencyKey('pre-approval-submit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0403', 'business code') }, ({ json }) => ({ orderId, rejectionCode: json.code })) + await ownedOrderSnapshot('order-before-approval', 'qualification rejection preserves complete order facts', orderId, qualificationBefore) + assertOrderUnchanged(qualificationFulfillmentBefore, + await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'after qualification rejection')) + report.qualificationRejectionEvidence = { orderId, orderSha256: orderDigest(qualificationBefore), fulfillmentSha256: orderDigest(qualificationFulfillmentBefore) } +} + +async function platformReviewerLogin() { + const platformLogin = await apiStep('qualification-review', 'platform reviewer login', { + method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'platform accessToken') + }, () => ({ username: platformUsername })) + platformToken = platformLogin.json.data.accessToken } async function qualificationAndReviewModule() { @@ -305,14 +323,6 @@ async function qualificationAndReviewModule() { assertEqual(json.data?.status, 'PENDING', 'tenant status') }, ({ json }) => ({ tenantId: json.data.id, status: json.data.status })) - const platformLogin = await apiStep('qualification-review', 'platform reviewer login', { - method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword }, - }, ({ json }) => { - requireSuccess(json) - assertPresent(json.data?.accessToken, 'platform accessToken') - }, () => ({ username: platformUsername })) - platformToken = platformLogin.json.data.accessToken - const dimensionPath = `/api/platform/v1/iam/tenants/${report.resources.tenantId}/dimension-initialization` for (let attempt = 0; attempt < 30; attempt += 1) { const statusResponse = await httpRequest({ method: 'GET', path: dimensionPath, token: platformToken }) @@ -2313,6 +2323,147 @@ async function orderMutationEvidenceModule() { ] } +async function submissionGateFulfillmentSnapshot(order, name) { + const module = 'order-submission-gates', customerId = String(report.resources.tenantId) + let task + await apiStep(module, `${name}: exact fulfillment task`, { + path: `/api/platform/v1/first-leg/tasks/by-order/${order.id}?tenantId=${customerId}`, token: platformToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'task lookup HTTP') + assertTrue(['200','A0404'].includes(String(json?.code)), 'task lookup must succeed or explicitly not exist') + task = String(json.code) === 'A0404' ? null : json.data ?? null + if (task) { + assertEqual(String(task.orderId), String(order.id), 'task source order') + assertEqual(String(task.tenantId), customerId, 'task customer') + } + }, () => ({ orderId: order.id, taskSha256: orderDigest(task) })) + const inbounds = [] + let total + for (let page = 1; ; page++) { + await apiStep(module, `${name}: inbound page ${page}`, { + path: `/api/platform/inbounds?tenantId=${customerId}&orderNo=${encodeURIComponent(order.orderNo)}&page=${page}&size=200`, token: platformToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), 'inbound records required') + assertTrue(Number.isSafeInteger(Number(json.data.total)) && Number(json.data.total) >= 0, 'inbound total required') + if (total === undefined) total = Number(json.data.total) + assertEqual(Number(json.data.total), total, 'stable inbound total') + assertEqual(Number(json.data.page), page, 'inbound page identity') + for (const row of json.data.records) { + assertEqual(row.orderNo, order.orderNo, 'exact inbound source') + assertEqual(String(row.tenantId), customerId, 'inbound customer') + assertTrue(!inbounds.some(before => String(before.id) === String(row.id)), 'no duplicate inbound across pages') + inbounds.push(row) + } + assertEqual(json.data.records.length, Math.min(200, Math.max(0, total - (page - 1) * 200)), 'complete inbound page') + }, () => ({ orderId: order.id, total, collected: inbounds.length, snapshotSha256: orderDigest(inbounds) })) + if (page * 200 >= total) break + } + return { task, inbounds } +} + +async function orderSubmissionBalanceGateModule() { + const module = 'order-submission-gates', customerId = String(report.resources.tenantId) + const proof = report.orderSubmissionGates = { customerId, balance: { status: 'PREPARING' }, remaining: [ + 'Channel-offline requires this run customer-owned price-book switch excluding the draft channel; no public channel or public price-book is changed.', + 'Finance/IAM unavailable-response rejection requires controlled isolated upstream fault evidence; not injected here.', + ] } + await apiStep(module, 'guard newly registered customer ownership before financial fixture', { + path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.id), customerId, 'current primary client identity') + assertEqual(json.data?.status, 'ACTIVE', 'approved active customer') + assertTrue(report.steps.some(step => step.module === 'registration' && step.name === 'enterprise self-registration' + && step.passed === true && String(step.evidence?.tenantId) === customerId), 'only this run newly registered customer may be adjusted') + }, () => ({ customerId, scope: 'current-run-registration-only' })) + const readWallet = async label => { + const response = await apiStep(module, label, { path: '/api/client/finance/v1/account', token: merchantToken }, ({ json }) => { + requireSuccess(json); assertTrue(Number.isSafeInteger(json.data?.version), 'wallet version required') + assertEqual(json.data.currencyCode, 'CNY', 'isolated wallet currency') + for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) { + assertTrue(json.data[field] != null && Number.isFinite(Number(json.data[field])), `wallet ${field} required`) + } + assertEqual(json.data.creditActive, false, 'no active credit may be altered') + }, ({ json }) => ({ customerId, account: json.data })) + return response.json.data + } + const readLedger = async label => { + const response = await apiStep(module, label, { path: '/api/client/finance/v1/account/ledger?page=1&size=200', token: merchantToken }, ({ json }) => { + requireSuccess(json); assertTrue(Array.isArray(json.data?.records), 'ledger records required') + assertEqual(Number(json.data.total), json.data.records.length, 'entire isolated ledger fits one page') + }, ({ json }) => ({ total: json.data.total, ledgerSha256: orderDigest(json.data.records) })) + return response.json.data.records + } + const baseline = await readWallet('freeze pristine account') + for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(baseline[field]), 0, `pristine ${field}`) + assertEqual((await readLedger('require no prior ledger')).length, 0, 'never debit an existing funded account') + const payload = { ...structuredClone(orderAcceptanceFixture.payload), customerRemark: `BALANCE-GATE-${marker}` } + const created = await apiStep(module, 'create only one fresh balance-gate draft', { + method:'POST', path:'/api/merchant/v1/orders/first-leg', token:merchantToken, body:payload, + headers:{'Idempotency-Key':newIdempotencyKey('balance-gate-create')}, + }, ({json})=>{requireSuccess(json);assertPresent(json.data,'gate order')}, ({json})=>({orderId:json.data})) + const orderId=created.json.data + report.resources.orderIds.push(orderId);proof.balance.orderId=orderId + const before = await ownedOrderSnapshot(module,'freeze rejected-submit draft facts',orderId) + assertEqual(before.order.status,'DRAFT','unfulfilled balance fixture') + const fulfillmentBefore = await submissionGateFulfillmentSnapshot(before.order,'before balance rejection') + const occurredDate = new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date()) + const referenceNo = `BALANCE-GATE-${marker}`.slice(0,128) + const adjust = async (type, account, suffix) => { + const command={headers:{'Idempotency-Key':newIdempotencyKey(`balance-${suffix}`)},body:{type,amount:0.01,occurredDate,paymentMethod:'OTHER', + referenceNo,remark:`${marker} isolated order gate ${suffix}`,expectedAccountVersion:account.version}} + proof.balance[`${suffix}Command`]=command + await apiStep(module,`isolated wallet ${suffix}`,{method:'POST',path:`/api/platform/finance/v1/accounts/${customerId}/adjustments`, + token:platformToken,...command},({json})=>requireSuccess(json),({json})=>({customerId,ledger:json.data})) + } + let attempted=false + try { + attempted=true + await adjust('DEDUCTION',baseline,'deduct') + const negative=await readWallet('verify actual balance minus one cent') + assertEqual(Number(negative.actualBalance),-0.01,'negative actual funds, not insufficient available credit') + assertEqual(negative.version,baseline.version+1,'one intentional deduction') + const negativeLedger=await readLedger('freeze deduction ledger') + assertEqual(negativeLedger.length,1,'only one fixture deduction') + for(const role of ['merchant','platform']) { + const command=orderMutationCommand(before,{},newIdempotencyKey(`negative-balance-${role}`)) + await apiStep(module,`${role} negative actual balance rejects submission`,{ + method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`, + token:role==='merchant'?merchantToken:platformToken,...command, + },({json,status})=>{assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0403','balance gate code'); + assertEqual(json?.message,'账户实际余额为负,请充值后再下单','balance-specific reason, not qualification rejection')},()=>({orderId,customerId,expectedVersion:command.body.expectedVersion})) + await ownedOrderSnapshot(module,`${role} rejected order aggregate unchanged`,orderId,before) + const after=await submissionGateFulfillmentSnapshot(before.order,`${role} rejection`) + assertOrderUnchanged(fulfillmentBefore,after) + assertOrderUnchanged(negative,await readWallet(`${role} rejection leaves wallet unchanged`)) + assertOrderUnchanged(negativeLedger,await readLedger(`${role} rejection leaves ledger unchanged`)) + } + proof.balance.status='REJECTIONS_VERIFIED' + } finally { + if(attempted) { + const current=await readWallet('resolve isolated deduction before restoring') + const ledger=await readLedger('resolve exact fixture ledger before restoring') + if(Number(current.actualBalance)===0 && current.version===baseline.version && ledger.length===0) proof.balance.restoration='DEDUCTION_NOT_APPLIED' + else { + assertEqual(Number(current.actualBalance),-0.01,'restore only exact isolated negative balance') + assertEqual(current.version,baseline.version+1,'restore requires unchanged deduction version') + assertTrue(ledger.length===1 && ledger[0].sourceType==='MANUAL_DEDUCTION' && ledger[0].referenceNo===referenceNo + && Number(ledger[0].amount)===-0.01,'restore only this run proven deduction; no blind credit') + await adjust('RECHARGE',current,'restore') + const restored=await readWallet('verify restored zero balance') + for(const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(restored[field]),Number(baseline[field]),`restored ${field}`) + assertEqual(restored.version,baseline.version+2,'one deduction and one restoring credit') + const finalLedger=await readLedger('verify exact two fixture ledger entries') + assertEqual(finalLedger.length,2,'no extra wallet movements') + assertTrue(finalLedger.every(row=>row.referenceNo===referenceNo) && finalLedger.filter(row=>row.sourceType==='MANUAL_DEDUCTION'&&Number(row.amount)===-0.01).length===1 + && finalLedger.filter(row=>row.sourceType==='MANUAL_RECHARGE'&&Number(row.amount)===0.01).length===1,'exact balanced fixture movements') + proof.balance.restoration='RESTORED_WITH_AUDIT' + } + } + } +} + async function importOrderSet(name, expectedIds) { const ids = [], seen = new Set() let total = null