This commit is contained in:
2026-08-21 16:04:16 +08:00
parent dceec6cbed
commit 0e7df9052a
68 changed files with 3985 additions and 267 deletions
@@ -87,11 +87,11 @@ async function createManualSchedule(context, suffix, oceanBookingId, customerId)
}
export async function run(context) {
context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官网自动查询',
'ZIM启用hCaptcha,本期禁止绕过;真实契约返回MANUAL_REQUIRED并提供官方入口和人工结果绑定',
context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官方API自动查询',
'ZIM Cargo Tracking API 未配置订阅凭据或来源授权时返回MANUAL_REQUIRED;不记录或回显订阅键',
'external-third-party')
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官网自动查询',
'COSCO为动态页面/iframe且没有稳定公开机器接口;真实契约返回MANUAL_REQUIRED,不伪造自动成功',
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询',
'COSCO Shipping Open Platform Cargo Tracking API 待注册订阅和凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF或伪造自动成功',
'external-third-party')
const suffix = context.marker.replace(/\D/g, '').slice(-10)
@@ -100,8 +100,10 @@ export async function run(context) {
const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item]))
context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse,
['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code))
&& byCode.get('ZIM')?.accessMode === 'HCAPTCHA' && byCode.get('ZIM')?.coverageGap === true
&& byCode.get('COSCO')?.accessMode === 'DYNAMIC_IFRAME' && byCode.get('COSCO')?.coverageGap === true
&& byCode.get('ZIM')?.accessMode === 'OFFICIAL_API' && byCode.get('ZIM')?.coverageGap === true
&& byCode.get('ZIM')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED'
&& byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGap === true
&& byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED'
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false),
`codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS)
@@ -130,29 +132,7 @@ export async function run(context) {
id(data(replay)?.query?.query?.id) === queryId,
`first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS)
let version = Number(created.query.query.version)
for (let attemptNo = 1; attemptNo <= 3; attemptNo += 1) {
const response = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: version, reason: `第${attemptNo}次官网查询` },
{ 'Idempotency-Key': `${context.marker}-carrier-attempt-${attemptNo}`.slice(0, 128) })
const detail = context.apiSuccess(MODULE, `第${attemptNo}次查询返回结构化人工兜底`, response, '', OPTIONS)
context.assert(MODULE, `第${attemptNo}次查询记录来源和审计状态`, response,
detail?.query?.query?.status === 'MANUAL_REQUIRED'
&& Number(detail?.query?.query?.attemptCount) === attemptNo
&& detail?.attempts?.length === attemptNo
&& detail?.attempts?.at(-1)?.coverageGap === true
&& detail?.attempts?.at(-1)?.coverageGapCode === 'HCAPTCHA_REQUIRED'
&& Boolean(detail?.query?.query?.lastAttemptAt) && Boolean(detail?.query?.query?.manualReason),
`status=${detail?.query?.query?.status || 'missing'}, attempts=${detail?.query?.query?.attemptCount ?? 'missing'}`, OPTIONS)
version = Number(detail?.query?.query?.version)
}
const fourth = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: version, reason: '超过窗口上限' },
{ 'Idempotency-Key': `${context.marker}-carrier-attempt-4`.slice(0, 128) })
step(context, '同一3天窗口第4次查询被服务端拒绝', fourth,
fourth.http === 200 && ['A0409', '409'].includes(codeOf(fourth)),
`HTTP=${fourth.http}, code=${codeOf(fourth)}`)
const version = Number(created.query.query.version)
const unknownCarrier = await request(context, 'POST', BASE,
{ ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` },
@@ -175,25 +155,17 @@ export async function run(context) {
{ 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) })
const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS)
context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual,
completed?.query?.query?.status === 'SUCCESS' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId
completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId
&& Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt)
&& Boolean(completed?.query?.nextAllowedAt) && completed?.query?.expired === false
&& completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
`status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS)
const early = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: Number(completed?.query?.query?.version), reason: '未到三天再次查询' },
{ 'Idempotency-Key': `${context.marker}-carrier-too-early`.slice(0, 128) })
step(context, '成功后未到3天禁止再次查询', early,
early.http === 200 && ['A0409', '409'].includes(codeOf(early)),
`HTTP=${early.http}, code=${codeOf(early)}`)
const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`)
const detail = context.apiSuccess(MODULE, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS)
context.assert(MODULE, '查询尝试和版本历史完整且不伪装自动成功', detailResponse,
detail?.attempts?.length === 3 && detail?.attempts?.every((item) => item.attemptStatus === 'MANUAL_REQUIRED')
context.assert(MODULE, '平台详情不暴露调度尝试且保留业务版本历史', detailResponse,
!Object.hasOwn(detail || {}, 'attempts')
&& detail?.histories?.some((item) => item.action === 'CREATE')
&& detail?.histories?.filter((item) => item.action === 'ATTEMPT').length === 3
&& detail?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
`attempts=${detail?.attempts?.length ?? 'missing'}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS)
`attemptsExposed=${Object.hasOwn(detail || {}, 'attempts')}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS)
}
@@ -4,7 +4,7 @@ import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs'
import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
@@ -14,9 +14,10 @@ function contextFor(routes) {
const calls = []
return {
calls,
request: async (method, requestPath) => {
calls.push({ method, path: requestPath })
assert.equal(method, 'GET')
marker: 'T-PHASE4', token: 'token',
request: async (method, requestPath, body, _token, headers = {}) => {
calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath })
assert.ok(['GET', 'POST'].includes(method))
assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`)
return response(routes[requestPath])
},
@@ -35,16 +36,22 @@ const task = {
id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001',
oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO',
trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED',
latestStandardNode: 'PICKUP_READY', latestNodeObservedAt: '2026-08-20T12:00:00',
latestActualNode: 'PICKED_UP', latestActualAt: '2026-08-20T12:00:00', version: 5,
lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00',
expiresAt: null, stoppedAt: '2026-08-20T12:01:00', stopReason: '目的港可提柜后停止',
expiresAt: null, stoppedAt: null, stopReason: null,
}
const node = {
id: '40001', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY',
nodeSequence: 60, observedAt: '2026-08-20T12:00:00', estimatedAt: null,
id: '40001', providerCode: 'COSCO', providerEventId: 'event-40001', providerNodeCode: 'OUTGATE', standardNode: 'PICKED_UP',
nodeSequence: 90, observedAt: '2026-08-20T12:00:00', estimatedAt: null,
actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles',
sourceReference: null, createdAt: '2026-08-20T12:00:01',
sourceReference: null, rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:00:01',
}
const manualNode = { ...node, id: '40002', providerCode: 'MANUAL', providerEventId: 'manual-40002',
providerNodeCode: 'PICKED_UP', estimatedAt: '2026-08-20T12:00:00', actualAt: null, rawPayloadHash: 'b'.repeat(64) }
const exception = { id: '45001', providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1',
providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true,
observedAt: '2026-08-20T12:00:00', locationCode: 'USLAX', locationName: 'Long Beach',
sourceReference: 'MATSON:9974749', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:00:01' }
const history = {
id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5,
reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00',
@@ -54,10 +61,12 @@ const carriers = [{
accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false,
}]
test('readonly module statically forbids writes and internal execution endpoints', () => {
test('module only permits the documented manual-node write and forbids internal execution endpoints', () => {
assert.match(source, /context\.request\('GET'/)
assert.doesNotMatch(source, /\bfetch\s*\(/)
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
assert.match(source, /context\.request\('POST',[\s\S]*\/manual-nodes/)
assert.match(source, /'Idempotency-Key'/)
for (const method of ['PUT', 'PATCH', 'DELETE']) {
assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`))
}
for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) {
@@ -65,12 +74,14 @@ test('readonly module statically forbids writes and internal execution endpoints
}
})
test('canonical task and node sets are locked to the phase-4 read contract', () => {
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED'])
test('canonical task node and exception sets are locked to the phase-4 contract', () => {
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED'])
assert.deepEqual(STANDARD_NODES, [
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT',
'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY',
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT',
'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY',
'PICKED_UP', 'EMPTY_RETURNED',
])
assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
})
test('empty page validates read-only pagination without detail calls', async () => {
@@ -90,10 +101,12 @@ test('existing task reads page, detail and history with nullable node times', as
const context = contextFor({
[`${base}/carriers`]: carriers,
[`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 },
[`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], histories: [history] },
[`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] },
[`${base}/${task.id}/manual-nodes`]: { query: { query: task, expired: false }, nodes: [node, manualNode], exceptions: [exception], histories: [history] },
[`${base}/${task.id}/history`]: [history],
})
await run(context)
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET'])
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET'])
assert.equal(context.calls.find((item) => item.method === 'POST').headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${task.id}`)
assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`)
})
@@ -2,11 +2,13 @@ const MODULE = 'phase4-carrier-nodes'
const OPTIONS = { scenario: 'phase4-carrier-nodes-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/carrier-schedule-queries'
export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED'])
export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'STOPPED'])
export const STANDARD_NODES = Object.freeze([
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT',
'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY',
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT',
'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY',
'PICKED_UP', 'EMPTY_RETURNED',
])
export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
function id(value) { return value == null ? '' : String(value) }
function number(value) { return Number.isFinite(Number(value)) }
@@ -18,21 +20,32 @@ function validTask(task) {
return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId))
&& QUERY_STATUSES.includes(task?.status)
&& nullableId(task?.containerBookingId)
&& nullableText(task?.latestStandardNode)
&& (task?.latestStandardNode == null || STANDARD_NODES.includes(task.latestStandardNode))
&& ['latestNodeObservedAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt']
&& nullableText(task?.latestActualNode)
&& (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode))
&& number(task?.version)
&& ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt']
.every((field) => nullableIso(task?.[field]))
&& nullableText(task?.stopReason)
}
function validNode(node) {
return Boolean(id(node?.id) && node?.providerCode && node?.providerNodeCode)
return Boolean(id(node?.id) && node?.providerCode && node?.providerEventId && node?.providerNodeCode)
&& STANDARD_NODES.includes(node?.standardNode)
&& number(node?.nodeSequence) && Number(node.nodeSequence) > 0
&& nullableIso(node?.observedAt) && node?.observedAt != null
&& nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt)
&& nullableText(node?.locationCode) && nullableText(node?.locationName)
&& nullableText(node?.sourceReference) && nullableIso(node?.createdAt)
&& typeof node?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(node.rawPayloadHash)
}
function validException(value) {
return Boolean(id(value?.id) && value?.providerCode && value?.providerEventId && value?.providerStatus)
&& EXCEPTION_CODES.includes(value?.exceptionCode) && typeof value?.active === 'boolean'
&& nullableIso(value?.observedAt) && value?.observedAt != null
&& nullableText(value?.locationCode) && nullableText(value?.locationName)
&& nullableText(value?.sourceReference) && nullableIso(value?.createdAt)
&& typeof value?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(value.rawPayloadHash)
}
function validHistory(item) {
@@ -71,12 +84,33 @@ export async function run(context) {
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`)
const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS)
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : []
const histories = Array.isArray(detail?.histories) ? detail.histories : []
context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse,
validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean'
&& Array.isArray(detail?.nodes) && nodes.every(validNode)
&& Array.isArray(detail?.exceptions) && exceptions.every(validException)
&& Array.isArray(detail?.histories) && histories.every(validHistory),
`status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, histories=${histories.length}`, OPTIONS)
`status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS)
const task = detail.query.query
const observedAt = task.latestActualAt || '2026-08-21T00:00:00'
const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, {
expectedVersion: task.version,
standardNode: task.latestActualNode || 'DEPARTED_WAREHOUSE',
observedAt,
estimatedAt: observedAt,
actualAt: null,
locationCode: null,
locationName: null,
reason: '四期船节点人工预计事实验收',
}, context.token, { 'Idempotency-Key': `${context.marker || 'phase4'}-carrier-manual-${queryId}`.slice(0, 128) })
const manualDetail = context.apiSuccess(MODULE, '人工补录预计节点事实', manualResponse, `queryId=${queryId}`, OPTIONS)
context.assert(MODULE, '人工预计事实可见且不改变任务操作态', manualResponse,
QUERY_STATUSES.includes(manualDetail?.query?.query?.status)
&& manualDetail?.nodes?.some((item) => item?.providerCode === 'MANUAL'
&& item?.estimatedAt != null && item?.actualAt == null),
`status=${manualDetail?.query?.query?.status || 'missing'}, nodes=${manualDetail?.nodes?.length ?? 'missing'}`, OPTIONS)
const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`)
const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS)