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 }) }