This commit is contained in:
2026-08-08 11:33:18 +08:00
parent 2ee78aafc2
commit 92b3b9b1fb
37 changed files with 17226 additions and 39 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement']
const DEFAULT_MODULES = ['customers', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'supplemental']
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -10,6 +10,7 @@
| `019fdac1-ae04-7032-bac0-968497ea54ad` | 墨家测量 | `inbound-measurement.mjs` |
| `019fd5af-2e85-74b0-94e5-98c1b9ea4d3e` | 船期价格表 | `pricing.mjs` |
| `019fdbb1-ca56-7263-8914-a29d96cc5177` | 客户端创建订单 | `orders.mjs`,负责创建隔离测试订单并补 Admin 状态流程 |
| `019fdf57-193c-7513-ac22-4ea62adaaf96` | Admin 补充边界验收 | `supplemental.mjs`,只读/负向/保护性契约,不创建或删除历史资源 |
## 统一交付要求
@@ -0,0 +1,235 @@
// Admin HTTP acceptance gaps that are intentionally independent from the CRUD
// modules. This module is read-only: it never creates or mutates business data.
const HEALTH_ENDPOINTS = [
{ path: '/api/platform/v1/orders/status', service: 'order-service' },
{ path: '/api/platform/v1/first-leg/status', service: 'firstleg-service' },
{ path: '/api/platform/v1/files/status', service: 'file-service' },
{ path: '/api/platform/v1/warehouses/status', service: 'warehouse-service', allows503: true },
]
function codeOf(response) {
return response?.body?.code == null ? 'missing' : String(response.body.code)
}
function isMissingRoute(response) {
return response?.http === 404 || response?.http === 405
|| /Not Found|No handler|No static resource/i.test(String(response?.raw || response?.body?.message || ''))
}
function isBusinessFailure(response) {
const code = codeOf(response)
return [400, 401, 403, 404, 409].includes(response?.http)
|| (response?.http === 200 && code !== '200')
}
function pageShape(data) {
return data && Array.isArray(data.records)
&& Number(data.current ?? data.page ?? 1) >= 1
&& Number(data.size ?? 0) >= 0
}
function xlsx(response) {
return response?.http === 200 && response.bytes?.length > 4
&& response.bytes[0] === 0x50 && response.bytes[1] === 0x4b
}
async function checkPage(context, module, name, path, options = {}) {
const response = await context.request('GET', path)
const data = context.apiSuccess(module, `${name}分页查询`, response, `path=${path}`, options)
context.assert(module, `${name}分页结构与记录数组正确`, response, pageShape(data),
`records=${data?.records?.length ?? 'n/a'}, current=${data?.current ?? data?.page ?? 'n/a'}, size=${data?.size ?? 'n/a'}`, options)
return { response, data }
}
export async function run(context) {
const module = 'supplemental'
const scenario = 'service-health-and-auth-boundary'
const blocker = { scenario, deploymentBlocker: true }
// Health endpoints are part of the deployed service contract. Warehouse is
// special: a schema/readiness failure is intentionally HTTP 503, not a fake
// success, so retain that signal in the report.
for (const endpoint of HEALTH_ENDPOINTS) {
const response = await context.request('GET', endpoint.path)
const notReadyEnvelope = endpoint.allows503 && response.http === 503 && codeOf(response) === '200'
const data = notReadyEnvelope
? response.body?.data
: context.apiSuccess(module, `${endpoint.service} 状态响应使用统一成功体`, response,
`HTTP=${response.http}, code=${codeOf(response)}`, isMissingRoute(response) ? blocker : {})
if (notReadyEnvelope) {
context.addStep(module, `${endpoint.service} readiness 失败仍返回统一响应体`, response, true,
`HTTP=503, code=${codeOf(response)}, status=${data?.status || 'missing'}`)
}
const shape = data && data.service === endpoint.service && typeof data.status === 'string'
&& (endpoint.service !== 'warehouse-service' || typeof data.message === 'string')
const ready = response.http === 200 && codeOf(response) === '200' && data?.status === 'UP'
const notReady = endpoint.allows503 && response.http === 503 && codeOf(response) === '200'
&& data && data.service === endpoint.service && data.status !== 'UP' && typeof data.message === 'string'
context.assert(module, `${endpoint.service} 状态字段完整且 readiness 语义明确`, response,
shape && (ready || notReady),
`service=${data?.service || 'missing'}, status=${data?.status || 'missing'}, readiness=${ready ? 'UP' : notReady ? 'NOT_READY' : 'invalid'}`,
isMissingRoute(response) ? blocker : {})
}
// Gateway must reject both absent and malformed bearer credentials before a
// platform handler is reached. This specifically guards against accidental
// route-level permitAll changes. No token or response body is persisted.
for (const [name, token] of [['未携带令牌', ''], ['伪造令牌', 'invalid-admin-token']]) {
const response = await context.request('GET', '/api/platform/v1/orders/status', undefined, token)
const rejected = response.http === 401 && ['A0401', '401'].includes(codeOf(response))
context.addStep(module, `平台接口${name}返回 401`, response, rejected,
`HTTP=${response.http}, code=${codeOf(response)}`)
}
// A route that exists but is not protected is an authorization regression;
// keep the check separate so a service outage does not look like a pass.
const protectedResponse = await context.request('GET', '/api/platform/v1/orders/status', undefined, '')
context.assert(module, '未认证请求不返回业务成功码', protectedResponse,
!(protectedResponse.http === 200 && codeOf(protectedResponse) === '200'),
`HTTP=${protectedResponse.http}, code=${codeOf(protectedResponse)}`)
// Warehouse/master-data read-only boundary checks. All resources are
// platform-public (tenantId=0); passing a customer scope must fail instead
// of silently widening or narrowing the result set.
const warehousePage = await checkPage(context, module, '仓库',
'/api/platform/v1/warehouse-admin?page=1&size=5&status=ACTIVE', { scenario: 'master-data-readonly' })
const warehouseId = String(warehousePage.data?.records?.[0]?.id || '')
if (warehouseId) {
await checkPage(context, module, '仓库筛选',
`/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=0&warehouseType=${encodeURIComponent(warehousePage.data.records[0].warehouseType || 'DOMESTIC')}`,
{ scenario: 'master-data-readonly' })
}
for (const [name, path] of [
['仓库非法状态', '/api/platform/v1/warehouse-admin?page=1&size=5&status=NOT_A_STATUS'],
['仓库客户作用域', '/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=10001'],
]) {
const response = await context.request('GET', path)
context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response),
`HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'master-data-readonly', deploymentBlocker: isMissingRoute(response) })
}
const locationBase = '/api/platform/v1/locations?page=1&size=5&tenantId=0'
const palletBase = '/api/platform/v1/pallets?page=1&size=5&tenantId=0'
const locations = await checkPage(context, module, '库位', locationBase, { scenario: 'warehouse-resources' })
const pallets = await checkPage(context, module, '托盘', palletBase, { scenario: 'warehouse-resources' })
if (warehouseId) {
await checkPage(context, module, '库位仓库筛选', `${locationBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' })
await checkPage(context, module, '托盘仓库筛选', `${palletBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' })
}
const resourceScopeChecks = [
['库位客户作用域', '/api/platform/v1/locations?page=1&size=5&tenantId=10001'],
['托盘客户作用域', '/api/platform/v1/pallets?page=1&size=5&tenantId=10001'],
['库位非法状态', `${locationBase}&status=ALLOCATED`],
]
for (const [name, path] of resourceScopeChecks) {
const response = await context.request('GET', path)
context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response),
`HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'warehouse-resources', deploymentBlocker: isMissingRoute(response) })
}
// A stale version is checked without identifying or mutating an existing
// resource; the sentinel ID must be rejected as not found/invalid.
for (const [name, path] of [
['库位过期版本/不存在资源', '/api/platform/v1/locations/999999999999?tenantId=0&warehouseId=999999999999'],
['托盘占用资源保护边界', '/api/platform/v1/pallets/999999999999?tenantId=0&warehouseId=999999999999'],
]) {
const response = await context.request('GET', path)
context.addStep(module, `${name}不泄露或误修改数据`, response, isBusinessFailure(response),
`HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'warehouse-resources', deploymentBlocker: isMissingRoute(response) })
}
// Shared express/partner master data: status/type filters and safe template
// downloads. We do not upload arbitrary or real customer workbooks.
for (const [label, base, invalid] of [
['快递公司', '/api/platform/v1/express-companies', 'UNKNOWN'],
['往来单位', '/api/platform/v1/partners', 'UNKNOWN_TYPE'],
]) {
await checkPage(context, module, label, `${base}?page=1&size=5&tenantId=0&status=ACTIVE`, { scenario: 'shared-master-data' })
const inactive = await checkPage(context, module, `${label}停用筛选`, `${base}?page=1&size=5&tenantId=0&status=INACTIVE`, { scenario: 'shared-master-data' })
context.assert(module, `${label}状态枚举过滤不混入其他状态`, inactive.response,
inactive.data?.records?.every?.((item) => item.status === 'INACTIVE'), `records=${inactive.data?.records?.length ?? 'n/a'}`, { scenario: 'shared-master-data' })
const invalidPath = label === '往来单位'
? `${base}?page=1&size=5&tenantId=0&types=${invalid}`
: `${base}?page=1&size=5&tenantId=0&status=${invalid}`
const invalidResponse = await context.request('GET', invalidPath)
context.addStep(module, `${label}无效筛选参数安全失败`, invalidResponse, isBusinessFailure(invalidResponse),
`HTTP=${invalidResponse.http}, code=${codeOf(invalidResponse)}`, { scenario: 'shared-master-data', deploymentBlocker: isMissingRoute(invalidResponse) })
}
for (const [label, path] of [
['仓库导入模板', '/api/platform/v1/warehouse-admin/import-template'],
['快递公司导入模板', '/api/platform/v1/express-companies/import-template'],
['往来单位导入模板', '/api/platform/v1/partners/import-template'],
]) {
const response = await context.requestBinary('GET', path)
context.addStep(module, `${label}为 XLSX 且不记录二进制`, response, xlsx(response),
`HTTP=${response.http}, bytes=${response.bytes?.length ?? 0}, contentType=${response.contentType || 'missing'}`,
{ scenario: 'shared-master-data', deploymentBlocker: isMissingRoute(response) })
}
// Price-book lifecycle is read-only here; pricing.mjs remains the sole owner
// of uploads/publication. Verify all supported status filters and source
// download evidence from an already existing active/history version.
const pricePath = '/api/platform/v1/fba-pricing/carrier-rates/price-books'
for (const status of ['PENDING_CONFIRMATION', 'ACTIVE', 'SUPERSEDED']) {
const response = await context.request('GET', `${pricePath}?status=${status}`)
const versions = context.apiSuccess(module, `船期价格表 ${status} 状态过滤`, response, `count=${Array.isArray(response.body?.data) ? response.body.data.length : 'n/a'}`, { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(response) })
context.assert(module, `船期价格表 ${status} 结果状态一致`, response,
Array.isArray(versions) && versions.every((item) => item.status === status), `count=${versions?.length ?? 'n/a'}`, { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(response) })
}
const activeVersionsResponse = await context.request('GET', `${pricePath}?status=ACTIVE`)
const activeVersions = context.apiSuccess(module, '读取现有生效价格版本用于下载安全检查', activeVersionsResponse, '', { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(activeVersionsResponse) })
const activeVersionId = String(activeVersions?.[0]?.id || '')
if (activeVersionId) {
const sourceResponse = await context.requestBinary('GET', `${pricePath}/${encodeURIComponent(activeVersionId)}/source`)
context.addStep(module, '现有生效价格源文件下载为 XLSX', sourceResponse, xlsx(sourceResponse),
`versionId=${activeVersionId}, bytes=${sourceResponse.bytes?.length ?? 0}, contentType=${sourceResponse.contentType || 'missing'}`, { scenario: 'price-book-readonly' })
} else {
context.assert(module, '存在可供安全下载核验的生效价格版本', activeVersionsResponse, false, '当前无 ACTIVE 价格版本,无法安全执行源文件下载核验', { scenario: 'price-book-readonly', deploymentBlocker: true })
}
// Download center: list/status pagination and negative access checks only;
// downloads.mjs owns task creation and successful-file polling.
for (const status of ['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED', 'EXPIRED']) {
const response = await context.request('GET', `/api/platform/v1/files/export-tasks?page=1&size=5&status=${status}`)
const data = context.apiSuccess(module, `下载中心 ${status} 状态分页`, response, '', { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) })
context.assert(module, `下载中心 ${status} 过滤结果状态一致`, response,
pageShape(data) && data.records.every((item) => item.status === status), `records=${data?.records?.length ?? 'n/a'}`, { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) })
}
for (const [name, path] of [
['不存在下载任务详情', '/api/platform/v1/files/export-tasks/999999999999'],
['不存在下载任务文件', '/api/platform/v1/files/export-tasks/999999999999/download'],
]) {
const response = await context.request('GET', path, undefined, context.token)
context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), `HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) })
}
const unauthDownload = await context.request('GET', '/api/platform/v1/files/export-tasks/999999999999/download', undefined, '')
context.addStep(module, '未授权下载请求被拒绝', unauthDownload, unauthDownload.http === 401,
`HTTP=${unauthDownload.http}, code=${codeOf(unauthDownload)}`, { scenario: 'download-readonly' })
// IAM lists are intentionally read-only. The RBAC endpoints are list-shaped
// arrays (not paginated in the current contract); retain that fact instead of
// pretending pagination exists, and verify built-in markers are exposed.
for (const [label, path, builtInField] of [
['平台用户', '/api/platform/v1/iam/users?page=1&size=5', 'protectedAccount'],
['平台角色', '/api/platform/v1/iam/roles', 'roleType'],
['菜单权限', '/api/platform/v1/iam/permissions', 'builtIn'],
]) {
const response = await context.request('GET', path)
const data = context.apiSuccess(module, `${label}只读列表`, response, '', { scenario: 'iam-readonly', deploymentBlocker: isMissingRoute(response) })
const records = label === '平台用户' ? data?.records : data
context.assert(module, `${label}列表结构正确且包含内置保护标识`, response,
label === '平台用户' ? pageShape(data)
: Array.isArray(records) && records.some((item) => label === '平台角色'
? item.roleType === 'SYSTEM'
: Object.hasOwn(item, builtInField)),
`records=${records?.length ?? 'n/a'}, builtInField=${builtInField}`, { scenario: 'iam-readonly', deploymentBlocker: isMissingRoute(response) })
}
for (const [name, path] of [
['角色列表伪造令牌', '/api/platform/v1/iam/roles'],
['菜单权限未携带令牌', '/api/platform/v1/iam/permissions'],
]) {
const response = await context.request('GET', path, undefined, name.includes('伪造') ? 'invalid-admin-token' : '')
context.addStep(module, `${name}拒绝访问`, response, response.http === 401,
`HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'iam-readonly' })
}
}
@@ -0,0 +1,670 @@
#!/usr/bin/env node
/**
* Client phase-two test-environment acceptance flow.
*
* The platform account is used only to discover deterministic inbound fixtures.
* All phase-two product assertions are executed with the Client account.
*
* Run directly and enter credentials when prompted:
* node scripts/client-phase2-test-env-acceptance.mjs
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
const args = parseArgs(process.argv.slice(2))
const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY
?? 'http://8.163.30.128:9000')
const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME
?? await promptText('二期客户端账号', 'merchant_admin')
const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD
?? await promptSecret('二期客户端密码')
const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME
?? await promptText('平台夹具查询账号', 'platform_admin')
const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD
?? await promptSecret('平台夹具查询密码')
const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? null
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports')
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000)
if (!clientPassword || !platformPassword) {
console.error('Client and platform passwords are required')
process.exit(2)
}
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
console.error('--timeout-ms must be at least 1000')
process.exit(2)
}
const now = new Date()
const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
const reportPath = resolve(reportDir, `client-phase2-test-env-${marker}.json`)
const report = {
marker,
gateway,
generatedAt: now.toISOString(),
completedAt: null,
success: false,
fatal: null,
testAccount: { username: clientUsername },
expectedTerminalState: {
processStatus: 'WAIT_SPLIT',
measureReviewStatus: 'APPROVED',
feeStatus: 'CONFIRMED',
},
resources: {
tenantId: null,
terminalInboundId: null,
terminalOrderId: null,
terminalInboundCode: null,
pendingOrderId: null,
foreignOrderId: null,
measurementImageFileIds: [],
weightImageFileIds: [],
},
steps: [],
}
let clientToken
let platformToken
let terminalFixture
let pendingFixture
let foreignFixture
let platformTerminalDetail
let merchantBilling
try {
await sessionModule()
await fixtureDiscoveryModule()
await unpublishedVisibilityModule()
await terminalBillingModule()
await unifiedOrderModule()
await isolationModule()
report.success = report.steps.every((step) => step.passed)
} catch (error) {
report.fatal = sanitizeError(error)
report.success = false
} finally {
report.completedAt = new Date().toISOString()
await mkdir(reportDir, { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
printSummary()
}
process.exitCode = report.success ? 0 : 1
async function sessionModule() {
const clientLogin = await apiStep('phase2-session', 'client login', {
method: 'POST', path: '/api/auth/login',
body: { username: clientUsername, password: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'client accessToken')
}, () => ({ username: clientUsername }))
clientToken = clientLogin.json.data.accessToken
const profile = await apiStep('phase2-session', 'load current enterprise profile', {
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: clientToken,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.id, 'enterprise id')
assertEqual(json.data?.status, 'ACTIVE', 'enterprise status')
}, ({ json }) => ({ tenantId: json.data.id, tenantName: json.data.tenantName, status: json.data.status }))
report.resources.tenantId = String(profile.json.data.id)
await apiStep('phase2-session', 'client has order view permission', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: clientToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(arrayData(json.data).includes('merchant:order:view'), 'merchant:order:view permission is required')
}, ({ json }) => ({ permissionCount: arrayData(json.data).length }))
const platformLogin = await apiStep('phase2-fixture', 'platform fixture account login', {
method: 'POST', path: '/api/auth/login',
body: { username: platformUsername, password: platformPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'platform accessToken')
}, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken
}
async function fixtureDiscoveryModule() {
const ownInbounds = await apiStep('phase2-fixture', 'discover current enterprise inbound fixtures', {
method: 'GET',
path: `/api/platform/inbounds?tenantId=${report.resources.tenantId}&page=1&size=200`,
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data?.records), 'inbound records must be an array')
}, ({ json }) => ({ total: json.data?.total, statusCounts: statusCounts(json.data?.records) }))
const ownRows = pageRecords(ownInbounds.json.data)
terminalFixture = forcedOrderId
? ownRows.find((row) => String(row.orderId) === String(forcedOrderId))
: ownRows.find(isTerminalFixture)
if (!terminalFixture) {
throw new Error(forcedOrderId
? `Configured orderId=${forcedOrderId} is not visible in tenant ${report.resources.tenantId}`
: `Tenant ${report.resources.tenantId} has no WAIT_SPLIT + APPROVED + CONFIRMED fixture`)
}
assertTerminalFixture(terminalFixture)
report.resources.terminalInboundId = String(terminalFixture.id)
report.resources.terminalOrderId = String(terminalFixture.orderId)
report.resources.terminalInboundCode = terminalFixture.inboundCode
pendingFixture = ownRows.find((row) => String(row.orderId) !== report.resources.terminalOrderId
&& !isTerminalFixture(row))
report.resources.pendingOrderId = pendingFixture == null ? null : String(pendingFixture.orderId)
await softApiStep('phase2-fixture', 'terminal state filters find selected inbound', {
method: 'GET',
path: `/api/platform/inbounds?tenantId=${report.resources.tenantId}`
+ '&processStatus=WAIT_SPLIT&measureReviewStatus=APPROVED&feeStatus=CONFIRMED&page=1&size=200',
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
const row = pageRecords(json.data)
.find((item) => String(item.id) === report.resources.terminalInboundId)
assertPresent(row, 'terminal inbound filter row')
assertTerminalFixture(row)
}, ({ json }) => ({ total: json.data?.total, inboundId: report.resources.terminalInboundId }))
const detail = await apiStep('phase2-fixture', 'load platform terminal inbound reference detail', {
method: 'GET',
path: `/api/platform/inbounds/${report.resources.terminalInboundId}?tenantId=${report.resources.tenantId}`,
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.summary?.id), report.resources.terminalInboundId, 'inbound id')
assertEqual(String(json.data?.summary?.orderId), report.resources.terminalOrderId, 'order id')
assertTerminalFixture(json.data?.summary)
}, ({ json }) => ({
inboundId: json.data.summary.id,
orderId: json.data.summary.orderId,
processStatus: json.data.summary.processStatus,
measureReviewStatus: json.data.summary.measureReviewStatus,
feeStatus: json.data.summary.feeStatus,
}))
platformTerminalDetail = detail.json.data
const allTerminal = await apiStep('phase2-fixture', 'discover foreign terminal fixture for isolation', {
method: 'GET',
path: '/api/platform/inbounds?page=1&size=200',
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data?.records), 'terminal fixture records must be an array')
}, ({ json }) => ({ total: json.data?.total }))
foreignFixture = pageRecords(allTerminal.json.data)
.find((row) => isTerminalFixture(row) && String(row.tenantId) !== report.resources.tenantId)
report.resources.foreignOrderId = foreignFixture == null ? null : String(foreignFixture.orderId)
}
async function unpublishedVisibilityModule() {
if (!pendingFixture) {
recordSkipped('phase2-client-visibility', 'unfinished inbound billing remains unavailable',
'Current enterprise has no non-terminal inbound fixture')
return
}
await apiStep('phase2-client-visibility', 'unfinished inbound billing remains unavailable', {
method: 'GET',
path: `/api/merchant/v1/first-leg/orders/${pendingFixture.orderId}/inbound-billing`,
token: clientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0404', 'business code')
}, ({ json }) => ({
orderId: pendingFixture.orderId,
processStatus: pendingFixture.processStatus,
feeStatus: pendingFixture.feeStatus,
rejectionCode: json?.code,
}))
await apiStep('phase2-client-visibility', 'unknown order inbound billing is rejected', {
method: 'GET', path: '/api/merchant/v1/first-leg/orders/1/inbound-billing', token: clientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0404', 'business code')
}, ({ json }) => ({ orderId: 1, rejectionCode: json?.code }))
}
async function terminalBillingModule() {
const response = await softApiStep('phase2-client-billing',
'terminal inbound billing is visible to client', {
method: 'GET',
path: `/api/merchant/v1/first-leg/orders/${report.resources.terminalOrderId}/inbound-billing`,
token: clientToken,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.inbound?.summary, 'merchant inbound summary')
assertPresent(json.data?.latestFee, 'merchant latest fee')
assertEqual(String(json.data.inbound.summary.id), report.resources.terminalInboundId, 'inbound id')
assertEqual(String(json.data.inbound.summary.orderId), report.resources.terminalOrderId, 'order id')
assertTerminalFixture(json.data.inbound.summary)
assertEqual(json.data.latestFee.feeStatus, 'CONFIRMED', 'latest fee status')
}, ({ json }) => ({
inboundId: json?.data?.inbound?.summary?.id,
orderId: json?.data?.inbound?.summary?.orderId,
processStatus: json?.data?.inbound?.summary?.processStatus,
measureReviewStatus: json?.data?.inbound?.summary?.measureReviewStatus,
feeStatus: json?.data?.latestFee?.feeStatus,
rejectionCode: json?.code,
}))
if (!response) {
recordSkipped('phase2-client-billing', 'terminal billing aggregate contract',
'Terminal billing endpoint did not return a visible aggregate')
recordSkipped('phase2-client-images', 'published measurement and weight image access',
'Terminal billing endpoint did not return image references')
return
}
merchantBilling = response.json.data
await localStep('phase2-client-billing', 'terminal billing aggregate contract', () => {
const summary = merchantBilling.inbound.summary
const fee = merchantBilling.latestFee
assertStringId(summary.id, 'inbound summary id')
assertStringId(summary.orderId, 'inbound order id')
assertStringId(fee.feeId, 'fee id')
assertStringId(fee.inboundId, 'fee inbound id')
assertEqual(String(fee.inboundId), String(summary.id), 'fee inbound relation')
assertEqual(fee.inboundCode, summary.inboundCode, 'fee inbound code')
assertEqual(fee.customerId == null ? null : String(fee.customerId), report.resources.tenantId,
'fee customer isolation')
assertTrue(Number(fee.actualWeightKg) > 0, 'actual weight must be positive')
assertTrue(Number(fee.actualVolumeCbm) > 0, 'actual volume must be positive')
assertTrue(Number(fee.chargeableQuantity) > 0, 'chargeable quantity must be positive')
assertTrue(Number(fee.unitPrice) >= 0, 'unit price must not be negative')
assertMoneyEqual(Number(fee.subtotalAmount) + Number(fee.additionalAmount),
Number(fee.totalAmount), 'fee amount composition')
assertEqual(fee.chargeUnit, fee.chargeMode, 'fee charge unit')
assertTrue(['KG', 'CBM'].includes(fee.chargeMode), 'charge mode must be KG or CBM')
}, {
inboundId: report.resources.terminalInboundId,
feeId: merchantBilling.latestFee.feeId,
feeNo: merchantBilling.latestFee.feeNo,
chargeMode: merchantBilling.latestFee.chargeMode,
chargeableQuantity: merchantBilling.latestFee.chargeableQuantity,
totalAmount: merchantBilling.latestFee.totalAmount,
currency: merchantBilling.latestFee.currency,
})
await localStep('phase2-client-billing', 'client aggregate excludes platform internal collections', () => {
assertEqual(arrayData(merchantBilling.inbound.internalNotes).length, 0, 'merchant internal note count')
assertEqual(arrayData(merchantBilling.inbound.statusHistory).length, 0, 'merchant status history count')
for (const file of arrayData(merchantBilling.inbound.files)) {
assertTrue(['PUBLIC', 'MERCHANT'].includes(file.visibility),
`merchant file visibility must be PUBLIC or MERCHANT, actual=${file.visibility}`)
}
assertEqual(merchantBilling.inbound.publicRemark ?? null,
platformTerminalDetail.publicRemark ?? null, 'public remark')
}, {
internalNoteCount: arrayData(merchantBilling.inbound.internalNotes).length,
statusHistoryCount: arrayData(merchantBilling.inbound.statusHistory).length,
visibleFileCount: arrayData(merchantBilling.inbound.files).length,
})
await localStep('phase2-client-billing', 'measurement and billing basis are internally consistent', () => {
const summary = merchantBilling.measurementSummary
const basis = merchantBilling.billingBasis
const fee = merchantBilling.latestFee
assertPresent(summary?.measurementId, 'measurement id')
assertEqual(summary.measurementStatus, 'SUBMITTED', 'measurement status')
assertTrue(Number(summary.actualPackageCount) > 0, 'actual package count must be positive')
assertTrue(Number(summary.actualGrossWeightKg) > 0, 'measurement gross weight must be positive')
assertTrue(Number(summary.actualVolumeCbm) > 0, 'measurement volume must be positive')
assertNumberEqual(basis.actualWeightKg, fee.actualWeightKg, 'billing actual weight')
assertNumberEqual(basis.actualVolumeCbm, fee.actualVolumeCbm, 'billing actual volume')
assertNumberEqual(basis.convertedQuantity, fee.convertedQuantity, 'billing converted quantity')
assertNumberEqual(basis.chargeableQuantity, fee.chargeableQuantity, 'billing chargeable quantity')
}, {
measurementId: merchantBilling.measurementSummary?.measurementId,
measurementStatus: merchantBilling.measurementSummary?.measurementStatus,
measurementItemCount: arrayData(merchantBilling.measurementItems).length,
weightResultCount: arrayData(merchantBilling.weightResults).length,
chargeableBasis: merchantBilling.billingBasis?.chargeableBasis,
})
const measurementImages = arrayData(merchantBilling.measurementImages)
const weightImages = arrayData(merchantBilling.weightImages)
report.resources.measurementImageFileIds = measurementImages.map((item) => String(item.fileId))
report.resources.weightImageFileIds = weightImages.map((item) => String(item.fileId))
const images = [...measurementImages, ...weightImages]
if (images.length === 0) {
recordSkipped('phase2-client-images', 'published measurement and weight image access',
'Terminal billing aggregate contains no image references')
} else {
for (const image of images.slice(0, 4)) {
await apiStep('phase2-client-images', `download ${image.imageType} image ${image.fileId}`, {
method: 'GET', path: apiPath(image.previewUrl), token: clientToken, responseType: 'bytes',
}, ({ status, bytes, headers }) => {
assertEqual(status, 200, 'HTTP status')
assertTrue(bytes?.length > 0, 'downloaded image must not be empty')
assertTrue((headers.get('content-type') ?? '').startsWith('image/'),
'download content type must be an image')
}, ({ bytes, headers }) => ({
fileId: image.fileId,
imageType: image.imageType,
sizeBytes: bytes.length,
contentType: headers.get('content-type'),
}))
}
}
await apiStep('phase2-client-images', 'unreferenced inbound image is rejected', {
method: 'GET',
path: `/api/merchant/v1/first-leg/orders/${report.resources.terminalOrderId}/inbound-billing/images/1`,
token: clientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0404', 'business code')
}, ({ json }) => ({ fileId: 1, rejectionCode: json?.code }))
}
async function unifiedOrderModule() {
const orderNo = terminalFixture.orderNo
await apiStep('phase2-unified-orders', 'unified order list exposes phase-two terminal state', {
method: 'GET',
path: `/api/merchant/v1/orders?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`,
token: clientToken,
}, ({ json }) => {
requireSuccess(json)
const row = pageRecords(json.data)
.find((item) => String(item.orderId) === report.resources.terminalOrderId)
assertPresent(row, 'unified terminal order row')
assertEqual(row.mainStatus, 'INBOUNDED', 'unified main status')
assertEqual(String(row.inboundId), report.resources.terminalInboundId, 'unified inbound id')
assertEqual(row.measurement?.reviewStatus, 'APPROVED', 'unified measurement review status')
assertTrue(Number(row.actualGrossWeightKg) > 0, 'unified actual gross weight must be positive')
assertTrue(Number(row.actualVolumeCbm) > 0, 'unified actual volume must be positive')
}, ({ json }) => {
const row = pageRecords(json.data)
.find((item) => String(item.orderId) === report.resources.terminalOrderId)
return { orderId: row?.orderId, mainStatus: row?.mainStatus,
inboundId: row?.inboundId, measurementReviewStatus: row?.measurement?.reviewStatus,
actualGrossWeightKg: row?.actualGrossWeightKg, actualVolumeCbm: row?.actualVolumeCbm }
})
await apiStep('phase2-unified-orders', 'unified order detail includes phase-two timeline', {
method: 'GET', path: `/api/merchant/v1/orders/${report.resources.terminalOrderId}`, token: clientToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.summary?.orderId), report.resources.terminalOrderId, 'unified order id')
assertEqual(json.data?.summary?.mainStatus, 'INBOUNDED', 'unified main status')
assertTrue(arrayData(json.data?.timeline).some((item) => item.status === 'INBOUNDED'),
'unified timeline must contain INBOUNDED')
}, ({ json }) => ({
orderId: json.data?.summary?.orderId,
mainStatus: json.data?.summary?.mainStatus,
timelineCount: arrayData(json.data?.timeline).length,
}))
}
async function isolationModule() {
if (!foreignFixture) {
recordSkipped('phase2-isolation', 'cross-enterprise terminal billing is rejected',
'No foreign WAIT_SPLIT + APPROVED + CONFIRMED fixture is visible to the platform account')
return
}
await apiStep('phase2-isolation', 'cross-enterprise terminal billing is rejected', {
method: 'GET',
path: `/api/merchant/v1/first-leg/orders/${foreignFixture.orderId}/inbound-billing`,
token: clientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertTrue(['A0403', 'A0404'].includes(String(json?.code)),
`expected A0403/A0404, actual=${json?.code}`)
}, ({ json }) => ({
orderId: foreignFixture.orderId,
tenantId: foreignFixture.tenantId,
rejectionCode: json?.code,
}))
}
async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
const startedAt = new Date().toISOString()
const started = Date.now()
let response
try {
response = await httpRequest(request)
validate(response)
report.steps.push(makeStep(module, name, true, startedAt, started, response, evidenceBuilder(response)))
console.log(`PASS [${module}] ${name}`)
return response
} catch (error) {
let evidence = null
if (response) {
try { evidence = evidenceBuilder(response) } catch { evidence = defaultEvidence(response) }
}
const step = makeStep(module, name, false, startedAt, started, response, evidence)
step.error = sanitizeError(error)
report.steps.push(step)
console.error(`FAIL [${module}] ${name}: ${error.message}`)
throw error
}
}
async function softApiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
try { return await apiStep(module, name, request, validate, evidenceBuilder) } catch { return null }
}
async function localStep(module, name, validate, evidence = null) {
const startedAt = new Date().toISOString()
const started = Date.now()
try {
validate()
report.steps.push(makeStep(module, name, true, startedAt, started, null, evidence))
console.log(`PASS [${module}] ${name}`)
} catch (error) {
const step = makeStep(module, name, false, startedAt, started, null, evidence)
step.error = sanitizeError(error)
report.steps.push(step)
console.error(`FAIL [${module}] ${name}: ${error.message}`)
throw error
}
}
async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) {
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-phase2-${randomUUID()}`, ...headers }
if (token) requestHeaders.Authorization = `Bearer ${token}`
let requestBody = body
if (body != null && !(body instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body)
}
const response = await fetch(`${gateway}${path}`, {
method, headers: requestHeaders, body: requestBody, signal: AbortSignal.timeout(timeoutMs),
})
const result = { status: response.status, headers: response.headers, json: null, bytes: null, text: null }
if (responseType === 'bytes') {
result.bytes = new Uint8Array(await response.arrayBuffer())
return result
}
const text = await response.text()
result.text = text
if (text) {
try { result.json = JSON.parse(text) }
catch { throw new Error(`Expected JSON but received ${text.slice(0, 200)}`) }
}
return result
}
function makeStep(module, name, passed, startedAt, started, response, evidence) {
return {
module, name, passed, skipped: false, startedAt, durationMs: Date.now() - started,
http: response?.status ?? null,
code: response?.json?.code == null ? null : String(response.json.code),
message: response?.json?.message ?? null,
requestId: headerOrBody(response, 'x-request-id', 'requestId'),
traceId: headerOrBody(response, 'x-trace-id', 'traceId'),
evidence,
}
}
function recordSkipped(module, name, reason) {
report.steps.push({
module, name, passed: true, skipped: true, startedAt: new Date().toISOString(), durationMs: 0,
http: null, code: null, message: reason, requestId: null, traceId: null, evidence: null,
})
console.log(`SKIP [${module}] ${name}: ${reason}`)
}
function requireSuccess(json) {
assertPresent(json, 'response body')
assertEqual(String(json.code), '200', 'business code')
}
function isTerminalFixture(row) {
return row?.processStatus === 'WAIT_SPLIT'
&& row?.measureReviewStatus === 'APPROVED'
&& row?.feeStatus === 'CONFIRMED'
}
function assertTerminalFixture(row) {
assertPresent(row, 'terminal inbound fixture')
assertEqual(row.processStatus, 'WAIT_SPLIT', 'process status')
assertEqual(row.measureReviewStatus, 'APPROVED', 'measurement review status')
assertEqual(row.feeStatus, 'CONFIRMED', 'fee status')
}
function statusCounts(values) {
const counts = {}
for (const row of arrayData(values)) {
const key = `${row.processStatus ?? 'UNKNOWN'}|${row.measureReviewStatus ?? 'NONE'}|${row.feeStatus ?? 'NONE'}`
counts[key] = (counts[key] ?? 0) + 1
}
return counts
}
function assertStringId(value, label) {
assertPresent(value, label)
assertEqual(typeof value, 'string', `${label} type`)
assertTrue(/^\d+$/.test(value), `${label} must contain only digits`)
}
function assertNumberEqual(actual, expected, label) {
assertEqual(Number(actual), Number(expected), label)
}
function assertMoneyEqual(actual, expected, label) {
assertEqual(Math.round(Number(actual) * 100), Math.round(Number(expected) * 100), label)
}
function defaultEvidence({ json }) {
if (!json || json.data == null) return null
if (typeof json.data !== 'object') return { data: json.data }
return null
}
function pageRecords(data) {
if (Array.isArray(data)) return data
return arrayData(data?.records ?? data?.items ?? data?.content)
}
function arrayData(value) { return Array.isArray(value) ? value : [] }
function headerOrBody(response, headerName, bodyName) {
return response?.headers?.get(headerName)
?? response?.json?.[bodyName]
?? response?.json?.data?.[bodyName]
?? null
}
function assertEqual(actual, expected, label) {
if (actual !== expected) throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`)
}
function assertPresent(value, label) {
if (value == null || value === '') throw new Error(`${label} is required`)
}
function assertTrue(value, message) {
if (!value) throw new Error(message)
}
function apiPath(value) {
const url = new URL(String(value), `${gateway}/`)
return `${url.pathname}${url.search}`
}
function printSummary() {
const failed = report.steps.filter((step) => !step.passed)
const skipped = report.steps.filter((step) => step.skipped)
console.log(JSON.stringify({
success: report.success,
marker,
passed: report.steps.filter((step) => step.passed && !step.skipped).length,
failed: failed.length,
skipped: skipped.length,
reportPath,
}, null, 2))
}
function parseArgs(values) {
const parsed = {}
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (!value.startsWith('--')) continue
const [rawKey, inlineValue] = value.slice(2).split('=', 2)
parsed[rawKey] = inlineValue ?? values[index + 1]
if (inlineValue == null) index += 1
}
return parsed
}
function compactTimestamp(value) {
return value.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)
}
function trimSlash(value) { return String(value).replace(/\/+$/, '') }
function format(value) { return typeof value === 'string' ? JSON.stringify(value) : String(value) }
function sanitizeError(error) {
return { name: error?.name ?? 'Error', message: error?.message ?? String(error) }
}
async function promptText(label, defaultValue = '') {
if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue
const reader = createInterface({ input: process.stdin, output: process.stdout })
try {
const suffix = defaultValue ? ` [${defaultValue}]` : ''
const value = await reader.question(`${label}${suffix}: `)
return value.trim() || defaultValue
} finally {
reader.close()
}
}
async function promptSecret(label) {
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined
const input = process.stdin
const output = process.stdout
output.write(`${label}: `)
input.setRawMode(true)
input.resume()
input.setEncoding('utf8')
let value = ''
try {
for await (const chunk of input) {
for (const character of chunk) {
if (character === '\r' || character === '\n') {
output.write('\n')
return value
}
if (character === '\u0003') throw new Error('Input cancelled')
if (character === '\u007f' || character === '\b') {
if (value.length > 0) value = value.slice(0, -1)
continue
}
value += character
}
}
return value
} finally {
input.setRawMode(false)
input.pause()
}
}
+4 -5
View File
@@ -725,13 +725,12 @@ async function rbacAndMemberModule() {
assertEqual(json.data?.status, 'DISABLED', 'role status')
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
if (disabledRole) {
await softApiStep('menu-permissions', 'disabled role is reflected by live permission query', {
await softApiStep('menu-permissions', 'disabled role revokes stale token permission query', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
requireSuccess(json)
assertEqual(arrayData(json.data).length, 0, 'effective backend permission count')
}, ({ json }) => ({ backendPermissions: json?.data, visibleClientMenus: visibleClientMenus(arrayData(json?.data)) }))
assertEqual(status, 401, 'HTTP status')
assertEqual(String(json?.code), 'A0401', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
await softApiStep('menu-permissions', 'disabled role blocks stale token order access', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
}, ({ json, status }) => {