153 lines
11 KiB
JavaScript
153 lines
11 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'].includes(arg)) {if(flags.has(arg))throw Error('Duplicate execution flag');flags.add(arg);continue}
|
|
if(!['--environment','--run-id','--output-dir','--tax-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.size>1)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')
|
|
return {execute,environment:'TEST',runId,taxMode,outputDir:values['--output-dir']||`reports/fba-core-${runId}`}
|
|
}
|
|
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),
|
|
modes:F40_MODES,module:'phase5-root-fulfillment',requiredEnvironmentKeys:REQUIRED_ENV_KEYS,
|
|
optionalEnvironmentKeys:['CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'],
|
|
behavior:'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',...F40_MODES.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:'F40 three 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=[]) {
|
|
if(Array.isArray(value))return value.map(item=>redactEvidence(item,secrets))
|
|
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([key,item])=>[key,
|
|
/^(?:.*password|.*token|authorization|cookie|gateway|jenkinsUrl|privateKey)$/i.test(key)?'[REDACTED]':redactEvidence(item,secrets)]))
|
|
if(typeof value==='string')return secrets.filter(secret=>typeof secret==='string'&&secret.length>0)
|
|
.reduce((text,secret)=>text.split(secret).join('[REDACTED]'),value)
|
|
return 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}={}) {
|
|
const scopeDescription=taxScope(config.taxMode)
|
|
const gateway=validateCoreEnvironment(env)
|
|
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,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 F40_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 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)}
|
|
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===F40_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}
|
|
}
|