test(order): prove foreign customer address rejection through real API probes
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
`client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-<run-id>/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。
|
||||
|
||||
模板检查器可另传显式 `--customer-profile=<同一授权目录下test-ui-*.env>`,仅替换该次独立会话的测试客户凭据,Gateway 与 TEST 环境保持由 `test-acceptance.env` 决定;拒绝生产、数据库文件和目录外路径。报告只保存权威客户 ID 的 SHA256,以 `compareCustomerTemplates` 对照两个不同客户的匹配结果;读取地址/模板不串客户与“提交外客户地址被拒绝”分开记录。
|
||||
|
||||
`client-import-template-test-env.mjs` 为批量导入模板只读 TEST 检查,默认 `--plan` 零网络。执行参数沿用 `--execute --environment=TEST --run-id=<新批次>`,另传 `--python=<load_workspace_dependencies返回的Python路径>`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-<run-id>/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { addressDraft, assertAddressRejected, execute } from './client-address-isolation-test-env.mjs'
|
||||
|
||||
test('negative probe plan does not require credentials or execute HTTP', async () => {
|
||||
assert.equal((await execute(['--plan'], [], {})).plan, true)
|
||||
})
|
||||
test('each payload probes exactly one foreign address field', () => {
|
||||
const args = [{ id: 'foreign' }, { id: 'domestic', warehouseName: '国内仓' }, { channelCode: 'SEA', channelName: '海运' }, { id: 'fba', warehouseCode: 'FBA-A', warehouseName: '目的仓' }]
|
||||
const pickup = addressDraft('PICKUP', ...args), destination = addressDraft('PRIVATE_DESTINATION', ...args)
|
||||
assert.equal(pickup.senderAddressId, 'foreign'); assert.equal(pickup.privateDestinationAddressId, undefined)
|
||||
assert.equal(pickup.destinationType, 'FBA'); assert.equal(pickup.destinationWarehouseId, 'fba')
|
||||
assert.equal(destination.privateDestinationAddressId, 'foreign'); assert.equal(destination.senderAddressId, undefined)
|
||||
assert.equal(destination.destinationType, 'PRIVATE'); assert.equal(destination.pickupMethod, 'SELF_TRUCK')
|
||||
})
|
||||
test('accepts only the precise address-boundary rejection, never unrelated validation or success', () => {
|
||||
const single = { http: 200, body: { code: 'A0404', message: '客户地址不存在或已停用', data: null } }
|
||||
const item = { success: false, orderId: null, errorCode: 'A0404', errorMessage: '客户地址不存在或已停用' }
|
||||
const batch = { http: 200, body: { code: '200', data: { total: 1, succeeded: 0, failed: 1, items: [item] } } }
|
||||
assert.doesNotThrow(() => assertAddressRejected(single))
|
||||
assert.doesNotThrow(() => assertAddressRejected(batch, true))
|
||||
for (const code of ['200', 'A0422', 'A0403']) assert.throws(() => assertAddressRejected({ ...single, body: { ...single.body, code } }))
|
||||
assert.throws(() => assertAddressRejected({ ...single, body: { ...single.body, message: '仓库不存在' } }))
|
||||
for (const change of [{ success: true, orderId: 'created' }, { errorCode: 'A0422' }, { errorMessage: '字段不能为空' }]) {
|
||||
assert.throws(() => assertAddressRejected({ ...batch, body: { code: '200', data: { ...batch.body.data, items: [{ ...item, ...change }] } } }, true))
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user