Files
baiyue-firstleg/scripts/fba-core-regression-test-env.mjs
T

196 lines
14 KiB
JavaScript

import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync } from 'node:fs'
import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createHash } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { createTestEvidenceReaders, validateEvidenceConfig, EVIDENCE_ENV_KEYS } from './fba-core-regression-evidence.mjs'
const repositoryRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..')
export const F40_MODES=Object.freeze(['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED'])
export const F40_TAX_MODES=Object.freeze(['BUY_DECLARATION','TAX_REFUND'])
export const REQUIRED_ENV_KEYS=Object.freeze(['CHONGCHONG_ADMIN_API_BASE_URL','CHONGCHONG_ADMIN_USERNAME',
'CHONGCHONG_ADMIN_PASSWORD','ADMIN_PDA_USERNAME','ADMIN_PDA_PASSWORD','FBA_CORE_TEST_GATEWAY_ORIGIN',...EVIDENCE_ENV_KEYS])
export function parseCoreArguments(argv) {
const values={},flags=new Set()
for(let i=0;i<argv.length;i++) {
const arg=argv[i]
if(['--plan','--execute','--verify-delivery-event-recovery'].includes(arg)) {if(flags.has(arg))throw Error('Duplicate execution flag');flags.add(arg);continue}
if(!['--environment','--run-id','--output-dir','--tax-mode','--signature-mode'].includes(arg)||values[arg]!==undefined||!argv[i+1]||argv[i+1].startsWith('--'))throw Error('Invalid FBA core argument')
values[arg]=argv[++i]
}
if(flags.has('--plan')&&flags.has('--execute'))throw Error('Choose plan or execute')
if(values['--environment'] && values['--environment']!=='TEST')throw Error('Only explicit TEST is supported')
const execute=flags.has('--execute'),runId=values['--run-id']||(!execute?'plan':null)
if(!runId||!/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(runId))throw Error('Fresh run-id is required')
if(execute&&values['--environment']!=='TEST')throw Error('Execution requires --environment TEST')
const taxMode=values['--tax-mode']||'BUY_DECLARATION'
if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode')
const signatureMode=values['--signature-mode']
if(signatureMode&&!F40_MODES.includes(signatureMode))throw Error('Unsupported F40 signature mode')
const verifyDeliveryEventRecovery=flags.has('--verify-delivery-event-recovery')
if(verifyDeliveryEventRecovery&&!signatureMode)throw Error('Delivery fault requires one explicit signature mode')
return {execute,environment:'TEST',runId,taxMode,signatureMode,verifyDeliveryEventRecovery,outputDir:values['--output-dir']||`reports/fba-core-${runId}`}
}
function selectedModes(config) {
if(config.signatureMode&&!F40_MODES.includes(config.signatureMode))throw Error('Unsupported F40 signature mode')
return config.signatureMode?[config.signatureMode]:F40_MODES
}
function taxScope(taxMode) {
if(!F40_TAX_MODES.includes(taxMode))throw Error('Unsupported F40 tax mode')
return taxMode==='TAX_REFUND'
? 'F40 TAX_REFUND: real customs upload, receipt confirmation, declaration/release, final documents, signatures and post-COMPLETED customer document reads/downloads.'
: 'F40 BUY_DECLARATION: existing buy-declaration shipment, signatures and remainder cancellation.'
}
export function executionPlan(config) {
return {environment:'TEST',execution:config.execute?'EXECUTE':'PLAN',runId:config.runId,outputDir:config.outputDir,
taxMode:config.taxMode,scope:taxScope(config.taxMode),verifyDeliveryEventRecovery:config.verifyDeliveryEventRecovery===true,
modes:selectedModes(config),module:'phase5-root-fulfillment',requiredEnvironmentKeys:REQUIRED_ENV_KEYS,
optionalEnvironmentKeys:['CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'],
behavior:config.verifyDeliveryEventRecovery
?'One explicit mode, first FBA receipt only: scoped ACK loss, original DEAD retry and verified Lastleg image/configuration restoration. HTTP creates fresh fixtures; SQL is read-only. Do not overlap deployments or other shared-environment writers.'
:'Serial fresh fixtures; stop on first failed mode. HTTP creates customers/orders/files and uses shared PDA/pallet resources. SQL is read-only; no deploy or database mutation.',
reports:['manifest.json','summary.json',...selectedModes(config).map(mode=>`${mode.toLowerCase()}.json`),'evidence/*.json'],
retention:'Exclusive run directory and evidence files. Interrupted lock requires operator review; no automatic cleanup or resume.',
scopeLimit:config.verifyDeliveryEventRecovery?'Only the selected first delivery event and listed F40 mode; not all reverse events, fee-void recovery, or UI acceptance.'
:'Only the listed F40 modes and receiving/completion evidence; not all F01-F40, UI acceptance or reverse event replay.'}
}
export function validateCoreEnvironment(env,root=repositoryRoot) {
if(basename(root)!=='baiyue-firstleg'||basename(dirname(root))!=='chongchong-global')throw Error('TEST workspace required')
if(REQUIRED_ENV_KEYS.some(key=>typeof env[key]!=='string'||!env[key].trim()))throw Error('Missing required TEST configuration')
let base,expected
try {base=new URL(env.CHONGCHONG_ADMIN_API_BASE_URL);expected=new URL(env.FBA_CORE_TEST_GATEWAY_ORIGIN)}catch{throw Error('Invalid TEST gateway configuration')}
if(!['http:','https:'].includes(base.protocol)||base.username||base.password||base.search||base.hash
||expected.href!==expected.origin+'/'||base.origin!==expected.origin
||/(?:^|[.-])(?:dev|prod|production)(?:[.-]|$)/i.test(base.hostname))throw Error('TEST gateway origin mismatch')
if(Boolean(env.CHONGCHONG_MERCHANT_USERNAME)!==Boolean(env.CHONGCHONG_MERCHANT_PASSWORD))throw Error('Incomplete optional merchant credentials')
validateEvidenceConfig(env)
return base.origin
}
export function redactEvidence(value,secrets=[]) {
// Long runs can register the same credential repeatedly. Normalize once per
// report, rather than filtering and reapplying it for every nested string.
const uniqueSecrets=[...new Set(secrets)].filter(secret=>typeof secret==='string'&&secret.length>0)
function redact(item) {
if(Array.isArray(item))return item.map(redact)
if(item&&typeof item==='object')return Object.fromEntries(Object.entries(item).map(([key,child])=>[key,
/^(?:.*password|.*token|authorization|cookie|gateway|jenkinsUrl|privateKey)$/i.test(key)?'[REDACTED]':redact(child)]))
if(typeof item==='string')return uniqueSecrets.reduce((text,secret)=>text.split(secret).join('[REDACTED]'),item)
return item
}
return redact(value)
}
export function modePassed(report) {
const proof=report?.createdResources?.phase5RootFulfillment
return report?.success===true&&report?.summary?.failed===0&&proof?.finalStatus==='VERIFIED'
&& !(report.deploymentBlockers||[]).length && !(report.steps||[]).some(step=>step.passed===false)
}
function safeFailure(error) {
const message=typeof error?.message==='string'?error.message:''
const mysql=/^MYSQL_([0-9]{3,5})$/.exec(message)
const http=/^TEST evidence HTTP ([1-5][0-9]{2})$/.exec(message)
return mysql?{code:'READ_ONLY_MYSQL_FAILED',mysqlError:Number(mysql[1])}
:http?{code:'READ_ONLY_HTTP_FAILED',http:Number(http[1])}:{code:'EXECUTION_FAILED'}
}
function createAcceptanceContext(mode,config,env,gateway) {
const options=validateExecutionOptions({...parseArguments(['--modules','phase5-root-fulfillment','--quiet','true']),
environment:'test',gateway,username:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD,
pdaUsername:env.ADMIN_PDA_USERNAME,pdaPassword:env.ADMIN_PDA_PASSWORD,
merchantUsername:env.CHONGCHONG_MERCHANT_USERNAME||'',merchantPassword:env.CHONGCHONG_MERCHANT_PASSWORD||'',
markerNamespace:createHash('sha256').update(config.runId+':'+mode).digest('hex').slice(0,12)})
return new AcceptanceRun(options)
}
export async function runFbaCore(config,{env=process.env,readers,createContext=createAcceptanceContext,outputRoot=repositoryRoot,faultFactory}={}) {
const scopeDescription=taxScope(config.taxMode)
const modes=selectedModes(config)
const gateway=validateCoreEnvironment(env)
if(config.verifyDeliveryEventRecovery&&(env.CHONGCHONG_ENV!=='test'||!config.signatureMode))throw Error('Delivery fault requires explicit TEST and one signature mode')
const output=resolve(outputRoot,config.outputDir),reports=resolve(outputRoot,'reports'),rel=relative(reports,output)
if(!config.execute||config.environment!=='TEST'||!rel||rel.startsWith('..')||isAbsolute(rel))throw Error('Fresh report directory inside reports required')
mkdirSync(reports,{recursive:true})
const lock=join(reports,'.fba-core-regression.lock')
const lockFd=openSync(lock,'wx')
const secrets=[...REQUIRED_ENV_KEYS,'CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD']
.filter(key=>/PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(key)).map(key=>env[key])
const summary={runId:config.runId,environment:'TEST',taxMode:config.taxMode,verifyDeliveryEventRecovery:config.verifyDeliveryEventRecovery===true,startedAt:new Date().toISOString(),success:false,modes:[],scope:scopeDescription}
let initialized=false
const merchantBefore={username:process.env.MERCHANT_TEST_USERNAME,password:process.env.MERCHANT_TEST_PASSWORD}
try {
writeFileSync(lockFd,JSON.stringify({runId:config.runId,pid:process.pid,startedAt:summary.startedAt}))
mkdirSync(output,{recursive:false});initialized=true
mkdirSync(join(output,'evidence'))
const save=(name,value)=>writeFileSync(join(output,name),JSON.stringify(redactEvidence(value,secrets),null,2)+'\n',{flag:'wx'})
save('manifest.json',executionPlan(config))
readers ||= createTestEvidenceReaders(env)
await readers.preflight()
for(const mode of modes) {
const context=createContext(mode,config,env,gateway)
let sequence=0
const evidence=async(kind,scope,read)=>{
const record={mode,taxMode:config.taxMode,kind,scope,capturedAt:new Date().toISOString(),captured:false}
try {record.evidence=await read(scope);record.captured=true;return record.evidence}
catch (error) {record.error='READ_ONLY_EVIDENCE_FAILED';record.diagnostic=safeFailure(error);throw Error('Read-only evidence failed')}
finally {save(`evidence/${mode.toLowerCase()}-${String(++sequence).padStart(4,'0')}-${kind}.json`,record)}
}
context.context.f40SignatureOrder=mode
context.context.f40TaxMode=config.taxMode
context.context.f40ReadOnlyOutboxEvidence=scope=>evidence('completion',scope,readers.readCompletion)
context.context.phase4ReceivingEvidenceReader=scope=>evidence('receiving',scope,readers.readReceiving)
let fault, faultPreparationAttempted=false
if(config.verifyDeliveryEventRecovery) {
if(typeof readers.readFleetInventory!=='function')throw Error('Complete raw inventory reader required for delivery recovery')
const factory=faultFactory||(await import('./test-event-fault-executor.mjs')).createTestDeliveryFaultExecutor
const executor=factory(env)
fault={...executor,prepare:async scope=>{
save(`evidence/${mode.toLowerCase()}-delivery-scope.json`,{scope,stage:'PREPARE_INTENT'})
faultPreparationAttempted=true;return executor.prepare(scope)
}}
context.context.f40DeliveryEventFaultExecutor=fault
context.context.f40ReadOnlyInventoryEvidence=scope=>evidence('inventory',scope,readers.readFleetInventory)
}
let report
try {report=await context.run()}
catch (error) {report={success:false,fatal:'FBA_CORE_MODE_EXCEPTION',diagnostic:safeFailure(error),summary:{failed:1},createdResources:context.createdResources}}
finally {
secrets.push(context.token,context.pdaToken,context.refreshToken,context.context?.merchantQualificationFixture?.password)
if(fault) {
try {
const restoration=await fault.cleanup()
save(`evidence/${mode.toLowerCase()}-delivery-restoration.json`,{attempted:faultPreparationAttempted,restoration:restoration??null})
if(faultPreparationAttempted&&(restoration?.restored!==true||restoration.healthy!==true||restoration.proxyStopped!==true))throw Error('Delivery restoration unproven')
} catch(error) {report={...report,success:false,deliveryRestorationFailed:true,restorationDiagnostic:safeFailure(error)}}
}
}
if(config.verifyDeliveryEventRecovery) {
const proof=report?.createdResources?.phase5RootFulfillment?.deliveryEventRecovery
if(proof?.status!=='VERIFIED')report={...report,success:false,deliveryRecoveryUnproven:true}
}
report={...report,regressionScope:{taxMode:config.taxMode,signatureOrder:mode,description:scopeDescription}}
const name=`${mode.toLowerCase()}.json`;save(name,report)
const passed=modePassed(report)
summary.modes.push({mode,taxMode:config.taxMode,report:name,passed,summary:report.summary,evidenceFiles:sequence})
if(!passed)break
}
summary.success=summary.modes.length===modes.length&&summary.modes.every(mode=>mode.passed)
} catch (error) {summary.failure='FBA_CORE_PREFLIGHT_OR_RUN_FAILED';summary.diagnostic=safeFailure(error)}
finally {
summary.finishedAt=new Date().toISOString()
try {
if(initialized)writeFileSync(join(output,'summary.json'),JSON.stringify(redactEvidence(summary,secrets),null,2)+'\n',{flag:'wx'})
} finally {
closeSync(lockFd);unlinkSync(lock)
for(const [key,value] of [['MERCHANT_TEST_USERNAME',merchantBefore.username],['MERCHANT_TEST_PASSWORD',merchantBefore.password]]) {
if(value===undefined)delete process.env[key];else process.env[key]=value
}
}
}
return summary
}
if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) {
try {
const config=parseCoreArguments(process.argv.slice(2))
const result=config.execute?await runFbaCore(config):executionPlan(config)
console.log(JSON.stringify(result,null,2));if(config.execute&&!result.success)process.exitCode=1
}catch {console.error('FBA core rejected configuration or existing run/lock; no automatic overwrite.');process.exitCode=1}
}