Files
baiyue-firstleg/scripts/client-address-isolation-test-env.mjs
T

135 lines
9.7 KiB
JavaScript

import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { parseEnv } from 'node:util'
import { createHash } from 'node:crypto'
import { executionOptions } from './client-workbench-test-env.mjs'
import { customerProfilePath } from './client-import-template-test-env.mjs'
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
class CheckFailure extends Error {}
function requireFact(ok, name) { if (!ok) throw new CheckFailure(name) }
export function addressDraft(kind, address, warehouse, channel, destination) {
return { customerName: '地址隔离验收', transportMode: 'SEA', tradeTerm: 'FOB', destinationCountryCode: 'US',
receivingWarehouseId: warehouse.id, receivingWarehouseName: warehouse.warehouseName,
channelCode: channel.channelCode, channelName: channel.channelName, taxMode: 'BUY_DECLARATION',
pricingTaxMode: 'TAX_INCLUDED', containerMode: 'LCL', customsInspection: false, fulfillmentType: 'STANDARD',
destinationType: kind === 'PICKUP' ? 'FBA' : 'PRIVATE', pickupMethod: kind === 'PICKUP' ? 'PICKUP' : 'SELF_TRUCK',
...(kind === 'PICKUP' ? { senderAddressId: address.id, destinationWarehouseId: destination.id,
destinationWarehouseName: destination.warehouseName, amazonWarehouseCode: destination.warehouseCode }
: { privateDestinationAddressId: address.id }),
cargoes: [{ cargoName: '地址边界测试货物', packageType: 'CARTON', packageCount: 1, grossWeightKg: 1,
volumeCbm: 0.001, unitsPerPackage: 1, totalQuantity: 1, woodenProduct: false, dangerousGoods: false }] }
}
export function assertAddressRejected(response, batch = false) {
const body = response.body
if (batch) {
requireFact(response.http === 200 && String(body?.code) === '200', 'Batch transport failed before row-level address validation')
const data = body.data
requireFact(data?.total === 1 && data.succeeded === 0 && data.failed === 1 && data.items?.length === 1, 'Unexpected batch success or shape')
const row = data.items[0]
requireFact(row.success === false && row.orderId == null && row.errorCode === 'A0404'
&& row.errorMessage === '客户地址不存在或已停用', 'Batch did not reject specifically at the customer-address boundary')
} else {
requireFact([200, 404].includes(response.http) && body?.code === 'A0404'
&& body.message === '客户地址不存在或已停用' && body.data == null, 'Create did not reject specifically at the customer-address boundary')
}
}
export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) {
if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true,
requests: 'Two independent logins; existing source addresses; two create and two single-row batch rejection probes; logout both sessions',
writes: 'Negative creation probes must return the precise address-not-found error; stop immediately on unexpected success or unknown outcome' }
requireFact(args.length === 4, 'Explicit TEST run and customer profile required')
const options = executionOptions(args.slice(0, 3), execArgs, env)
const profilePath = customerProfilePath(args[3], execArgs[0].slice(11))
const control = parseEnv(await readFile(profilePath, 'utf8'))
requireFact(control.MERCHANT_TEST_USERNAME && control.MERCHANT_TEST_PASSWORD, 'Missing control credentials')
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const directory = resolve(root, 'reports', `client-address-isolation-${options.runId}`)
await mkdir(directory)
const report = { startedAt: new Date().toISOString(), success: false, requests: [], checks: [], sessionsClosed: [] }
const sessions = []
const save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(report, null, 2))
async function request(session, method, path, body, key) {
requireFact(method === 'GET' || ['/auth/login', '/auth/logout', '/merchant/v1/orders/first-leg', '/merchant/v1/orders/first-leg/batch'].includes(path), 'Unexpected write endpoint')
const entry = { session: session.name, method, path: path.split('?')[0], outcome: 'PENDING', key,
payloadSha256: body && !path.startsWith('/auth/') ? digest(body) : undefined }
report.requests.push(entry); await save()
try {
const result = await fetch(options.base + path, { method, signal: AbortSignal.timeout(30000), redirect: 'error',
headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}),
...(session.token ? { Authorization: `Bearer ${session.token}` } : {}), ...(key ? { 'Idempotency-Key': key } : {}) }, body: body ? JSON.stringify(body) : undefined })
const payload = await result.json()
Object.assign(entry, { outcome: 'RECEIVED', http: result.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId })
if (method === 'POST' && path.startsWith('/merchant/v1/orders/first-leg')) {
entry.returnedOrderIds = Array.isArray(payload.data?.items) ? payload.data.items.filter(r => r.orderId != null).map(r => String(r.orderId))
: typeof payload.data === 'string' || typeof payload.data === 'number' ? [String(payload.data)] : []
entry.rowErrorCodes = payload.data?.items?.map(r => r.errorCode)
}
await save()
return { http: result.status, body: payload }
} catch (error) { entry.outcome = 'UNKNOWN_STOP'; await save(); throw error }
}
async function successful(session, method, path, body) {
const response = await request(session, method, path, body)
requireFact(response.http === 200 && response.body?.code === '200', 'Setup or cleanup request rejected')
return response.body.data
}
try {
for (const [name, username, password] of [['source', env.CHONGCHONG_MERCHANT_USERNAME, env.CHONGCHONG_MERCHANT_PASSWORD],
['control', control.MERCHANT_TEST_USERNAME, control.MERCHANT_TEST_PASSWORD]]) {
const session = { name }; sessions.push(session)
const auth = await successful(session, 'POST', '/auth/login', { loginId: username, password })
session.token = auth?.accessToken; session.refresh = auth?.refreshToken
requireFact(session.token && session.refresh, 'Incomplete login')
session.profile = await successful(session, 'GET', '/merchant/v1/iam/tenant/profile')
requireFact(session.profile?.id != null, 'Missing authenticated customer identity')
}
const [source, target] = sessions
requireFact(String(source.profile.id) !== String(target.profile.id), 'Two different customers required')
report.customerIdentitySha256 = sessions.map(s => digest(String(s.profile.id)))
const warehouses = await successful(target, 'GET', '/merchant/v1/warehouses?warehouseType=DOMESTIC&status=ACTIVE&page=1&size=200')
const warehouse = warehouses.records.find(w => w.warehouseType === 'DOMESTIC' && w.status === 'ACTIVE')
requireFact(warehouse, 'A real active domestic warehouse is required')
const channels = await successful(target, 'GET', `/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`)
requireFact(channels.length > 0, 'A real channel is required')
const destinationPage = await successful(target, 'GET', '/merchant/v1/warehouses?warehouseType=FBA&status=ACTIVE&page=1&size=200')
const destination = destinationPage.records.find(w => w.warehouseType === 'FBA' && w.status === 'ACTIVE')
requireFact(destination, 'A real active FBA destination is required')
for (const kind of ['PICKUP', 'PRIVATE_DESTINATION']) {
const path = `/merchant/v1/orders/first-leg/addresses?addressType=${kind}`
const foreign = await successful(source, 'GET', path), own = await successful(target, 'GET', path)
requireFact(foreign.length > 0, 'Source address sample missing')
requireFact(!own.some(a => String(a.id) === String(foreign[0].id)), 'Source address leaked into target directory')
const draft = addressDraft(kind, foreign[0], warehouse, channels[0], destination)
for (const batch of [false, true]) {
const key = `ADDR-${options.runId}-${kind}-${batch ? 'batch' : 'single'}`
const body = batch ? [{ clientRowId: kind, draft }] : draft
const response = await request(target, 'POST', '/merchant/v1/orders/first-leg' + (batch ? '/batch' : ''), body, key)
assertAddressRejected(response, batch)
report.checks.push({ kind, endpoint: batch ? 'batch' : 'single', passed: true, payloadSha256: digest(body) })
await save()
}
}
report.success = true
report.limitation = 'Proves JSON single/batch address rejection; XLSX upload parsing and database zero-drift evidence remain separate'
} catch (error) {
report.failure = error instanceof CheckFailure ? error.message : 'Transport, setup or response format failure; no automatic retry'
} finally {
for (const session of sessions) if (session.token) {
try { await successful(session, 'POST', '/auth/logout', { refreshToken: session.refresh }); report.sessionsClosed.push(session.name) }
catch { report.success = false; report.logoutFailure = true }
}
report.success = report.success && report.sessionsClosed.length === 2
report.completedAt = new Date().toISOString(); await save()
}
return { success: report.success, checks: report.checks.length, report: resolve(directory, 'report.json') }
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 })
.catch(() => { console.error('Address isolation probe did not start; check explicit TEST profile and new run-id.'); process.exitCode = 1 })
}