This commit is contained in:
2026-08-21 10:38:28 +08:00
parent 361e6be3be
commit d5e9ba6d4c
21 changed files with 1705 additions and 15 deletions
+65 -4
View File
@@ -10,9 +10,10 @@ import { pathToFileURL } from 'node:url'
import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
export function normalizeModuleOrder(modules) {
const normalized = [...modules]
@@ -131,13 +132,14 @@ function usage() {
选项:
--gateway <url> Gateway 地址,默认 ${DEFAULT_GATEWAY}
--overseas-gateway <url> 海外 Gateway 地址,默认 ${DEFAULT_OVERSEAS_GATEWAY}
--username <name> 管理员账号,默认 ${DEFAULT_USERNAME}
--password <value> 管理员密码,默认使用上方测试密码
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
--output <path> JSON 报告路径,默认 reports/
--help 显示帮助
环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`)
环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`)
}
export function parseArguments(argv) {
@@ -158,6 +160,7 @@ export function parseArguments(argv) {
}
return {
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''),
overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || DEFAULT_OVERSEAS_GATEWAY).replace(/\/$/, ''),
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME,
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD,
modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)),
@@ -189,6 +192,7 @@ export class AcceptanceRun {
this.coverageGaps = []
this.context = {}
this.token = ''
this.overseasToken = ''
this.fatal = null
}
@@ -215,6 +219,29 @@ export class AcceptanceRun {
}
}
async requestOverseas(method, path, body = undefined, extraHeaders = {}) {
const headers = { Accept: 'application/json', ...extraHeaders }
if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}`
if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
try {
const response = await fetch(`${this.options.overseasGateway}${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
})
const content = await response.text()
let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ }
return {
method, path, http: response.status, body: payload,
raw: payload === null ? content.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
}
async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) {
const form = file instanceof FormData ? file : new FormData()
if (!(file instanceof FormData)) {
@@ -266,6 +293,31 @@ export class AcceptanceRun {
}
}
async requestOverseasBinary(method, path) {
const headers = { Accept: '*/*' }
if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}`
try {
const response = await fetch(`${this.options.overseasGateway}${path}`, {
method, headers, signal: AbortSignal.timeout(120_000),
})
const bytes = new Uint8Array(await response.arrayBuffer())
const contentType = response.headers.get('content-type') || ''
let payload = null
if (contentType.toLowerCase().includes('json')) {
try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ }
}
return {
method, path, http: response.status, body: payload, bytes, contentType,
raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '',
raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
}
addStep(module, name, response, passed, evidence = '', options = {}) {
const step = {
module, scenario: options.scenario || null, name, passed: Boolean(passed),
@@ -792,6 +844,14 @@ export class AcceptanceRun {
if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块'
else {
this.token = login.accessToken
this.overseasToken = login.overseasAccessToken || ''
const requiresOverseas = this.options.modules.some((module) => module.startsWith('phase4-overseas-'))
if (requiresOverseas) {
this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse,
Boolean(this.overseasToken) && Number(login.overseasExpiresIn) > 0,
`tokenPresent=${Boolean(this.overseasToken)}, expiresIn=${login.overseasExpiresIn ?? 'missing'}`,
{ deploymentBlocker: true, scenario: 'phase4-dual-token' })
}
const handlers = { customers: () => this.runCustomers() }
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
const module = this.options.modules[moduleIndex]
@@ -856,7 +916,7 @@ export class AcceptanceRun {
deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps,
createdResources: this.createdResources,
})
const forbiddenValues = [this.options.password, this.token].filter((value) => typeof value === 'string' && value.length >= 8)
const forbiddenValues = [this.options.password, this.token, this.overseasToken].filter((value) => typeof value === 'string' && value.length >= 8)
const sensitivePatterns = [
/"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i,
/\bBearer\s+[A-Za-z0-9._~-]{16,}/i,
@@ -872,7 +932,8 @@ export class AcceptanceRun {
const failed = this.steps.filter((step) => !step.passed).length
return {
reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker,
gateway: this.options.gateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt,
gateway: this.options.gateway, overseasGateway: this.options.overseasGateway,
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
success: this.fatal === null && failed === 0, fatal: this.fatal,
summary: { total: this.steps.length, passed: this.steps.length - failed, failed },
modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers,