Files
baiyue-firstleg/scripts/fee-void-recovery-test-env.mjs

171 lines
13 KiB
JavaScript

import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, existsSync } from 'node:fs'
import { dirname, resolve, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { isDeepStrictEqual } from 'node:util'
import { createHash } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
import { redactEvidence } from './fba-core-regression-test-env.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const FEES = '/api/platform/v1/last-leg/finance-fee-sources'
const TYPE = 'OVERSEAS_WAREHOUSE_FEE'
const id = value => String(value ?? '')
const check = (value, message) => { if (!value) throw Error(message) }
export function parseFeeVoidArguments(args) {
const config = { execute: false, environment: 'TEST', runId: 'plan' }, seen = new Set()
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (['--plan', '--execute'].includes(arg)) {
check(!seen.has('action'), 'Choose one execution action'); seen.add('action'); config.execute = arg === '--execute'; continue
}
const field = { '--environment': 'environment', '--run-id': 'runId', '--customer-id': 'customerId', '--control-customer-id': 'controlCustomerId', '--warehouse-id': 'warehouseId' }[arg]
check(field && !seen.has(arg) && args[i + 1] && !args[i + 1].startsWith('--'), 'Invalid or duplicate argument')
seen.add(arg); config[field] = args[++i]
}
validateConfig(config)
if (config.execute) check(seen.has('--environment'), 'Execution requires explicit TEST')
return config
}
function validateConfig(config) {
check(config.environment === 'TEST' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId), 'TEST and safe run-id required')
if (config.execute) check(config.runId !== 'plan' && ['customerId', 'controlCustomerId', 'warehouseId'].every(key => typeof config[key] === 'string' && /^[1-9][0-9]{0,19}$/.test(config[key]))
&& config.customerId !== config.controlCustomerId, 'Two explicit distinct customers and warehouse required')
}
export function feeVoidPlan(config) {
return { environment: 'TEST', execute: config.execute, runId: config.runId, customerId: config.customerId, controlCustomerId: config.controlCustomerId,
warehouseId: config.warehouseId, output: `reports/fee-void-${config.runId}`, sourceType: TYPE, amount: 17.31, currency: 'CNY',
stages: ['read customer/warehouse and isolation baselines', 'create/submit/approve new warehouse service fee', 'wait original publication',
'inject only VOIDED ACK loss', 'original DEAD retry and full runtime restore', 'same-command replay and isolation readback'],
limits: 'Only a new unbilled TEST receivable. Retain fixtures and failures, never automatically restart. No business SQL writes. Exclusive Lastleg fault window; no overlapping deployments or writers.' }
}
export async function runFeeVoid(config, { env = process.env, outputRoot = ROOT, readers, faultFactory,
contextFactory = options => new AcceptanceRun(options), wait = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
validateConfig(config)
if (!config.execute) return feeVoidPlan(config)
check(env.CHONGCHONG_ENV === 'test' && env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'Explicit TEST credentials required')
const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
check(['http:', 'https:'].includes(base.protocol) && ['/api', '/api/'].includes(base.pathname) && !base.username && !base.password && !base.search && !base.hash, 'Invalid TEST gateway')
readers ||= createTestEvidenceReaders(env)
faultFactory ||= (await import('./test-event-fault-executor.mjs')).createTestFeeVoidFaultExecutor
check(typeof faultFactory === 'function' && typeof readers.readFleetInventory === 'function', 'Fee fault executor and complete inventory reader required')
const reports = resolve(outputRoot, 'reports'), output = join(reports, `fee-void-${config.runId}`)
mkdirSync(reports, { recursive: true })
for (const name of ['.fba-core-regression.lock', '.fleet-acceptance.lock']) check(!existsSync(join(reports, name)), 'Another shared-environment acceptance is running or needs review')
const lockPath = join(reports, '.test-event-recovery.lock'), lock = openSync(lockPath, 'wx')
const report = { success: false, stage: 'PREFLIGHT', requests: [], inventory: [], goalComplete: false }
let context, fault, initialized = false, refreshToken, attempted = false, prepared = false
const secrets = [env.CHONGCHONG_ADMIN_PASSWORD, env.JENKINS_API_TOKEN]
const save = () => { if (initialized) writeFileSync(join(output, 'report.json'), JSON.stringify(redactEvidence(report, secrets), null, 2)) }
try {
writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId }))
mkdirSync(output); initialized = true
writeFileSync(join(output, 'manifest.json'), JSON.stringify(feeVoidPlan(config), null, 2))
await readers.preflight()
context = contextFactory(validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']),
environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }))
report.marker = context.marker
const request = async (method, path, body, headers = {}) => {
report.pending = { method, path, body }; save()
const result = await context.request(method, path, body, context.token, headers)
report.requests.push({ method, path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId })
delete report.pending; save()
check(result.http === 200 && id(result.body?.code) === '200', `Fee recovery request failed: ${method} ${path} (${result.http}/${result.body?.code})`)
return result.body.data
}
const login = await request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD })
check(login?.accessToken && login.refreshToken, 'Complete administrator session required')
context.token = login.accessToken; refreshToken = login.refreshToken; secrets.push(context.token, refreshToken)
const customers = []
for (const customerId of [config.customerId, config.controlCustomerId]) {
const customer = await request('GET', `/api/platform/v1/iam/tenants/${customerId}`)
check(id(customer.id) === customerId && customer.status === 'ACTIVE' && customer.tenantName, 'Exact active customer required')
customers.push(customer)
}
const warehouse = await request('GET', `/api/platform/v1/warehouse-admin/${config.warehouseId}?tenantId=0`)
check(id(warehouse.warehouse?.id) === config.warehouseId && id(warehouse.warehouse.tenantId) === '0' && warehouse.warehouse.status === 'ACTIVE'
&& warehouse.warehouse.warehouseType === 'OVERSEAS', 'Exact active overseas warehouse required')
const feeList = customerId => request('GET', `${FEES}?${new URLSearchParams({ customerId })}`)
const pool = customerId => request('GET', `/api/platform/finance/v1/fee-pool?${new URLSearchParams({ customerId, direction: 'RECEIVABLE' })}`)
const snapshot = async () => {
const value = []
for (const customerId of [config.customerId, config.controlCustomerId]) {
const sources = await feeList(customerId), finance = await pool(customerId)
check(Array.isArray(sources) && sources.every(row => id(row.customerId) === customerId) && Array.isArray(finance) && finance.every(row => id(row.customerId) === customerId), 'Customer fee scope mismatch')
const inventory = await readers.readFleetInventory({ marker: context.marker, customerId, warehouseId: config.warehouseId })
value.push({ customerId, sources, finance, inventory })
}
return value
}
report.before = await snapshot()
report.stage = 'CREATE_NEW_FEE'
const body = { customerId: config.customerId, customerName: customers[0].tenantName, sourceType: TYPE, businessType: 'WAREHOUSE_SERVICE',
businessId: null, businessNo: `${context.marker}-VOID-TEST`, warehouseId: config.warehouseId,
feeItemCode: 'TEST-VOID-ACK', feeItemName: '受控测试作废响应丢失费用', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
currency: 'CNY', amount: 17.31, paymentVoucherFileIds: [] }
report.createBody = body
let fee = await request('POST', FEES, body, { 'Idempotency-Key': `${context.marker}-CREATE` })
report.sourceFeeId = id(fee.sourceFeeId)
check(/^[1-9][0-9]+$/.test(report.sourceFeeId) && fee.reviewStatus === 'DRAFT' && id(fee.customerId) === config.customerId && Number(fee.sourceVersion) === 1, 'New isolated fee draft required')
const path = `${FEES}/${report.sourceFeeId}`
fee = await request('POST', `${path}/submit`, { idempotencyKey: `${context.marker}-SUBMIT` })
check(fee.reviewStatus === 'PENDING', 'New fee must await review')
fee = await request('POST', `${path}/review`, { decision: 'APPROVE', reason: context.marker, idempotencyKey: `${context.marker}-REVIEW` })
check(fee.reviewStatus === 'APPROVED' && fee.publicationStatus === 'PUBLISHED' && fee.sourceStatus === 'ACTIVE', 'Approved active source required')
report.approved = fee
let published = []
for (let i = 0; i < 30; i++) {
published = (await pool(config.customerId)).filter(row => id(row.sourceId) === report.sourceFeeId && Number(row.sourceVersion) === 1)
if (published.length) break
await wait(1000)
}
check(published.length === 1 && Number(published[0].amount) === 17.31 && published[0].currency === 'CNY', 'Original approved fee must exist exactly once in Finance')
report.originalFinance = published
report.scope = { marker: context.marker, customerId: config.customerId, sourceFeeId: report.sourceFeeId, sourceVersion: '1', sourceType: TYPE }
fault = faultFactory(env)
report.stage = 'PREPARE_INTENT'; attempted = true; save()
await fault.prepare(report.scope)
prepared = true
report.stage = 'VOID_REQUEST'
report.voidRequest = { expectedVersion: 1, reason: '受控TEST作废确认响应丢失验收', idempotencyKey: `${context.marker}-VOID` }
const pending = await request('POST', `${path}/void`, report.voidRequest)
check(pending.sourceStatus === 'ACTIVE' && pending.voidStatus === 'PENDING_FINANCE', 'Sending void request must not confirm business-side cancellation')
report.pendingSource = pending; report.stage = 'WAIT_DEAD_AND_RECOVER'; save()
report.eventProof = await fault.readProof(report.scope)
report.stage = 'RESTORED_READBACK'; save()
const confirmed = (await feeList(config.customerId)).filter(row => id(row.sourceFeeId) === report.sourceFeeId)
check(confirmed.length === 1 && confirmed[0].sourceStatus === 'VOIDED' && confirmed[0].voidStatus === 'CONFIRMED' && Number(confirmed[0].sourceVersion) === 1, 'Matching Finance ACK must confirm only the original source version')
report.confirmedSource = confirmed[0]
const replay = await request('POST', `${path}/void`, report.voidRequest)
check(isDeepStrictEqual(replay, confirmed[0]), 'Same void command replay changed the confirmed source')
report.after = await snapshot()
check(isDeepStrictEqual(report.before[1], report.after[1]), 'Other customer fees or inventory changed')
check(isDeepStrictEqual(report.before[0].inventory, report.after[0].inventory), 'Fee cancellation changed inventory')
check(isDeepStrictEqual(report.before[0].sources, report.after[0].sources.filter(row => id(row.sourceFeeId) !== report.sourceFeeId)), 'Unrelated original source changed')
check(isDeepStrictEqual(report.before[0].finance, report.after[0].finance), 'Voided fee remains in pool or unrelated finance row changed')
report.success = true; report.stage = 'VERIFIED'
} catch (error) { report.error = error.message; report.success = false; report.stage = 'FAILED' }
finally {
if (fault) try {
report.restoration = await fault.cleanup()
const noMutation = !prepared && report.restoration?.attempted === false
if (attempted && !noMutation && !(report.restoration?.restored && report.restoration?.healthy && report.restoration?.proxyStopped)) throw Error('Fee void restoration not verified')
} catch (error) { report.success = false; report.stage = 'RESTORATION_FAILED'; report.restorationError = error.message }
if (context?.token) try {
const result = await context.request('POST', '/api/auth/logout', { refreshToken }, context.token)
report.sessionClosed = result.http === 200 && id(result.body?.code) === '200'
if (!report.sessionClosed) report.success = false
} catch (error) { report.success = false; report.sessionClosed = false; report.logoutError = error.message }
try { save() } finally { closeSync(lock); unlinkSync(lockPath) }
}
return report
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const config = parseFeeVoidArguments(process.argv.slice(2)), result = await runFeeVoid(config)
console.log(JSON.stringify(config.execute ? { success: result.success, stage: result.stage, error: result.error, sourceFeeId: result.sourceFeeId, restored: result.restoration?.restored, output: feeVoidPlan(config).output } : result, null, 2))
if (config.execute && !result.success) process.exitCode = 1
} catch (error) { console.error(error.message); process.exitCode = 1 }
}