完成单机TEST统一入口与可靠投递回归收尾
This commit is contained in:
@@ -286,7 +286,6 @@ function usage() {
|
||||
|
||||
选项:
|
||||
--gateway <url> 必填,国内 Gateway 地址
|
||||
--overseas-gateway <url> 四期海外模块必填,海外 Gateway 地址
|
||||
--username <name> 必填,管理员账号
|
||||
--password <value> 必填,管理员密码
|
||||
--pda-username <name> 国内装柜链必填,实际领取任务的 PDA 作业员账号
|
||||
@@ -315,7 +314,7 @@ function usage() {
|
||||
--output <path> JSON 报告路径,默认 reports/
|
||||
--help 显示帮助
|
||||
|
||||
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
|
||||
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
|
||||
ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME,
|
||||
MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`)
|
||||
}
|
||||
@@ -336,6 +335,8 @@ export function parseArguments(argv) {
|
||||
values.set(name, next); index += 1
|
||||
}
|
||||
}
|
||||
if (values.has('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Remove overseas-gateway / ADMIN_OVERSEAS_GATEWAY; use one TEST gateway')
|
||||
if (values.has('environment') && values.get('environment') !== 'test') throw new Error('Only test acceptance is supported')
|
||||
const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(','))
|
||||
.split(',').map((v) => v.trim()).filter(Boolean)
|
||||
const modules = normalizeModuleOrder(requestedModules)
|
||||
@@ -354,8 +355,8 @@ export function parseArguments(argv) {
|
||||
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
|
||||
}
|
||||
return {
|
||||
environment: 'test',
|
||||
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''),
|
||||
overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''),
|
||||
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '',
|
||||
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '',
|
||||
pdaUsername: values.get('pda-username') || process.env.ADMIN_PDA_USERNAME || '',
|
||||
@@ -396,10 +397,8 @@ export function validateExecutionOptions(options) {
|
||||
for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) {
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`)
|
||||
}
|
||||
if (options.modules.some((module) => module.startsWith('phase4-overseas-'))
|
||||
&& (!options.overseasGateway || options.overseasGateway.trim() === '')) {
|
||||
throw new Error('四期海外模块缺少必填执行参数: overseas-gateway')
|
||||
}
|
||||
if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported')
|
||||
if ('overseasGateway' in options) throw new Error('Use the single gateway option')
|
||||
if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module))
|
||||
&& (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) {
|
||||
throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)')
|
||||
@@ -561,8 +560,7 @@ export class AcceptanceRun {
|
||||
this.token = ''
|
||||
this.pdaToken = ''
|
||||
this.refreshToken = ''
|
||||
this.overseasToken = ''
|
||||
this.overseasExpiresAt = 0
|
||||
this.expiresAt = 0
|
||||
this.fatal = null
|
||||
}
|
||||
|
||||
@@ -597,29 +595,6 @@ export class AcceptanceRun {
|
||||
}
|
||||
throw new Error('unreachable request attempt state')
|
||||
}
|
||||
async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) {
|
||||
const headers = { Accept: 'application/json', ...extraHeaders }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
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 = {}) {
|
||||
if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken
|
||||
const form = file instanceof FormData ? file : new FormData()
|
||||
@@ -631,31 +606,6 @@ export class AcceptanceRun {
|
||||
return this.requestWithBody(method, path, form, token, extraHeaders)
|
||||
}
|
||||
|
||||
async requestOverseasMultipart(method, path, file, token = this.overseasToken, extraHeaders = {}) {
|
||||
const form = file instanceof FormData ? file : new FormData()
|
||||
if (!(file instanceof FormData)) {
|
||||
if (!file || file.content === undefined) throw new TypeError('multipart file requires content')
|
||||
const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content)
|
||||
form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin')
|
||||
}
|
||||
const headers = { Accept: 'application/json', ...extraHeaders }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
try {
|
||||
const response = await fetch(`${this.options.overseasGateway}${path}`, {
|
||||
method, headers, body: form, signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const content = await response.text(); let payload = null
|
||||
try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ }
|
||||
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 requestWithBody(method, path, body, token = this.token, extraHeaders = {}) {
|
||||
const headers = { Accept: 'application/json', ...extraHeaders }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
@@ -706,31 +656,6 @@ export class AcceptanceRun {
|
||||
throw new Error('unreachable binary request attempt state')
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -947,24 +872,16 @@ export class AcceptanceRun {
|
||||
async refreshSession(reason) {
|
||||
const response = await this.request('POST', '/api/auth/refresh',
|
||||
{ refreshToken: this.refreshToken }, '')
|
||||
const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response,
|
||||
`reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken)
|
||||
&& Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0
|
||||
this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid,
|
||||
`accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`,
|
||||
{ scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
const next = this.apiSuccess('authentication', '刷新统一会话', response,
|
||||
`reason=${reason}`, { scenario: 'session-refresh', deploymentBlocker: true })
|
||||
const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) && Number(next?.expiresIn) > 0
|
||||
this.assert('authentication', '刷新响应完整后原子替换会话', response, valid,
|
||||
`accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}`,
|
||||
{ scenario: 'session-refresh', deploymentBlocker: true })
|
||||
if (!valid) return false
|
||||
const replacement = {
|
||||
token: next.accessToken,
|
||||
refreshToken: next.refreshToken,
|
||||
overseasToken: next.overseasAccessToken,
|
||||
overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000,
|
||||
}
|
||||
this.token = replacement.token
|
||||
this.refreshToken = replacement.refreshToken
|
||||
this.overseasToken = replacement.overseasToken
|
||||
this.overseasExpiresAt = replacement.overseasExpiresAt
|
||||
this.token = next.accessToken
|
||||
this.refreshToken = next.refreshToken
|
||||
this.expiresAt = Date.now() + Number(next.expiresIn) * 1000
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1809,23 +1726,12 @@ export class AcceptanceRun {
|
||||
`tokenPresent=${Boolean(this.pdaToken)}`, { deploymentBlocker: true, scenario: 'phase3-pda' })
|
||||
}
|
||||
this.refreshToken = login.refreshToken || ''
|
||||
this.overseasToken = login.overseasAccessToken || ''
|
||||
this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0
|
||||
? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0
|
||||
const requiresOverseas = this.options.modules.some((module) =>
|
||||
module.startsWith('phase4-overseas-') || module.startsWith('phase5-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' })
|
||||
}
|
||||
this.expiresAt = Number(login.expiresIn) > 0 ? Date.now() + Number(login.expiresIn) * 1000 : 0
|
||||
const handlers = { customers: () => this.runCustomers() }
|
||||
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
|
||||
const module = this.options.modules[moduleIndex]
|
||||
if ((module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-'))
|
||||
&& this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) {
|
||||
await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`)
|
||||
if (this.expiresAt > 0 && Date.now() + 120_000 >= this.expiresAt) {
|
||||
await this.refreshSession(`模块 ${module} 执行前Token临近到期`)
|
||||
}
|
||||
const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name))
|
||||
if (module === 'finance' && fullFinanceChain) {
|
||||
@@ -1913,12 +1819,11 @@ export class AcceptanceRun {
|
||||
if (module === 'system' && laterOverseasModule) {
|
||||
const refreshed = await this.refreshSession('system模块权限与角色变更完成')
|
||||
if (refreshed) {
|
||||
const probe = await this.requestOverseas('GET',
|
||||
'/api/platform/v1/last-leg/orders?page=1&size=1')
|
||||
this.assert('authentication', 'system后新admin海外Token只读探针可用', probe,
|
||||
const probe = await this.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
|
||||
this.assert('authentication', 'system后新admin统一Token只读探针可用', probe,
|
||||
probe.http === 200 && String(probe.body?.code) === '200',
|
||||
`HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`,
|
||||
{ scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
{ scenario: 'session-refresh', deploymentBlocker: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1929,7 +1834,7 @@ export class AcceptanceRun {
|
||||
createdResources: this.createdResources,
|
||||
})
|
||||
const forbiddenValues = [this.options.password, this.options.merchantPassword,
|
||||
this.token, this.refreshToken, this.overseasToken]
|
||||
this.token, this.refreshToken]
|
||||
.filter((value) => typeof value === 'string' && value.length >= 8)
|
||||
const sensitivePatterns = [
|
||||
/"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i,
|
||||
@@ -1946,7 +1851,7 @@ export class AcceptanceRun {
|
||||
const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps)
|
||||
return {
|
||||
reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker,
|
||||
gateway: this.options.gateway, overseasGateway: this.options.overseasGateway,
|
||||
gateway: this.options.gateway, gateway: this.options.gateway,
|
||||
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
|
||||
success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
|
||||
summary,
|
||||
|
||||
Reference in New Issue
Block a user