135 lines
9.7 KiB
JavaScript
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 })
|
|
}
|