Files

240 lines
16 KiB
JavaScript
Raw Permalink Normal View History

2026-08-08 11:33:18 +08:00
// 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 }
2026-08-26 11:10:42 +08:00
const customerId = String(context.context.merchantQualificationFixture?.tenantId || '')
context.assert(module, '客户作用域负向检查使用本轮正式客户', null, Boolean(customerId),
`customerId=${customerId || 'missing'}`, blocker)
if (!customerId) return
2026-08-08 11:33:18 +08:00
// 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'],
2026-08-26 11:10:42 +08:00
['仓库客户作用域', `/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`],
2026-08-08 11:33:18 +08:00
]) {
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 = [
2026-08-26 11:10:42 +08:00
['库位客户作用域', `/api/platform/v1/locations?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`],
['托盘客户作用域', `/api/platform/v1/pallets?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`],
2026-08-08 11:33:18 +08:00
['库位非法状态', `${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' })
}
}