diff --git a/scripts/README.md b/scripts/README.md index 243ab654d..8588d99d4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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-/`,不记录凭据、地址明文或账号。该项不等于 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=`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。 diff --git a/scripts/client-address-isolation-test-env.mjs b/scripts/client-address-isolation-test-env.mjs new file mode 100644 index 000000000..5df8763e9 --- /dev/null +++ b/scripts/client-address-isolation-test-env.mjs @@ -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 }) +} diff --git a/scripts/client-address-isolation-test-env.test.mjs b/scripts/client-address-isolation-test-env.test.mjs new file mode 100644 index 000000000..62b95626a --- /dev/null +++ b/scripts/client-address-isolation-test-env.test.mjs @@ -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)) + } +})