From c9a1e93299067393a4c45899bb4e846f5f14f6ab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 19:32:57 +0800 Subject: [PATCH] test(firstleg): retry lost evidence uploads with the same command --- scripts/admin-test-env-modules/README.md | 2 + ...hase3-evidence-transport-contract.test.mjs | 86 +++++++++++++++++++ .../admin-test-env-modules/phase3-loading.mjs | 49 ++++++++--- 3 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-evidence-transport-contract.test.mjs diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 70aee2b05..ee4ee0940 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,8 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 +`phase3-loading.mjs` 的 PDA 原子证据上传在未取得 HTTP 响应时最多尝试三次,冻结原 URL、作业员身份、幂等键、文件名/内容、证据版本和封签号;不重试 HTTP/业务拒绝或已收到响应后的读取失败。`createdResources.loadingEvidenceTransport` 保存各次结果及白名单网络错误码,不保存凭据或原始错误对象。`phase3-evidence-transport-contract.test.mjs` 用持久幂等模型验证响应丢失重放仅一条证据、持续传输失败有界终止及拒绝不重试;离线模型不替代真实 HTTP。 + `phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。 `AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原取消请求重放须 `A0409`,且根子版本、历史、摘要、履约树及完成 Outbox 零漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。 diff --git a/scripts/admin-test-env-modules/phase3-evidence-transport-contract.test.mjs b/scripts/admin-test-env-modules/phase3-evidence-transport-contract.test.mjs new file mode 100644 index 000000000..3eab0b96a --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-evidence-transport-contract.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { uploadEvidence } from './phase3-loading.mjs' + +const context = () => ({ marker: 'CURRENT-SEAL', options: { gateway: 'https://offline.invalid' }, + pdaToken: 'secret-test-token', token: 'different-admin-token', createdResources: {} }) +const invoke = ctx => uploadEvidence(ctx, '2105979722985902081', '193000000000200001', 'SEAL', 0, 'SEAL', { sealNo: 'SEAL-UNIQUE' }) +async function withTransport(handler, check) { + const originalFetch = globalThis.fetch, originalTimeout = globalThis.setTimeout + const waits = [] + globalThis.fetch = handler + globalThis.setTimeout = (callback, delay) => { waits.push(delay); queueMicrotask(callback); return 0 } + try { await check(waits) } finally { globalThis.fetch = originalFetch; globalThis.setTimeout = originalTimeout } +} +function lost(code = 'ECONNRESET') { + return new TypeError('fetch failed https://private-host.invalid secret-test-token', { cause: { code, address: 'private-host.invalid' } }) +} + +test('committed upload with lost response replays frozen content/key and creates exactly one modeled evidence row', async () => { + const ctx = context(), records = new Map(), calls = [] + let mutations = 0 + await withTransport(async (url, options) => { + const file = options.body.get('file') + const hash = createHash('sha256').update(`${url}|${file.name}|${file.type}|${file.size}|`) + .update(Buffer.from(await file.arrayBuffer())).digest('hex') + const key = options.headers['Idempotency-Key'] + calls.push({ url, key, hash, auth: options.headers.Authorization, body: options.body }) + if (records.has(key)) assert.equal(records.get(key).hash, hash, 'same-key different payload must be rejected') + else { records.set(key, { hash, evidenceId: 'persisted-evidence' }); mutations++ } + if (calls.length === 1) { + ctx.marker = 'CHANGED'; ctx.pdaToken = 'new-token'; ctx.options.gateway = 'https://changed.invalid' + throw lost() + } + return new Response(JSON.stringify({ code: '200', data: { evidenceId: records.get(key).evidenceId } }), { status: 200 }) + }, async waits => { + const result = await invoke(ctx) + assert.equal(result.body.data.evidenceId, 'persisted-evidence') + assert.equal(mutations, 1); assert.equal(records.size, 1); assert.equal(calls.length, 2) + assert.deepEqual(calls[0], calls[1]); assert.deepEqual(waits, [250]) + assert.deepEqual(result.transportAttempts.map(item => item.outcome), ['NO_HTTP_RESPONSE', 'HTTP_RESPONSE']) + assert.equal(result.transportAttempts[0].causeCode, 'ECONNRESET') + const evidence = JSON.stringify(ctx.createdResources) + for (const secret of ['secret-test-token', 'new-token', 'private-host', 'offline.invalid', 'changed.invalid']) assert.equal(evidence.includes(secret), false) + }) +}) + +test('persistent no-response failures stop at three attempts with bounded backoff and sanitized cause', async () => { + let calls = 0 + await withTransport(async () => { calls++; throw lost('UND_ERR_CONNECT_TIMEOUT') }, async waits => { + const result = await invoke(context()) + assert.equal(calls, 3); assert.equal(result.http, 0); assert.equal(result.body, null) + assert.deepEqual(waits, [250, 500]); assert.equal(result.transportAttempts.length, 3) + assert.match(result.raw, /UND_ERR_CONNECT_TIMEOUT/) + assert.doesNotMatch(JSON.stringify(result), /private-host|secret-test-token|address/) + }) +}) + +for (const [http, code] of [[200, 'A0409'], [200, 'A0422'], [503, 'S0503'], [403, 'A0403']]) { + test(`HTTP ${http} business ${code} is returned without retry`, async () => { + let calls = 0 + await withTransport(async () => { calls++; return new Response(JSON.stringify({ code, message: 'rejected', requestId: 'request' }), { status: http }) }, async waits => { + const result = await invoke(context()) + assert.equal(calls, 1); assert.deepEqual(waits, []); assert.equal(result.http, http) + assert.equal(result.body.code, code); assert.equal(result.requestId, 'request') + }) + }) +} + +test('received HTTP response with unreadable body is not reclassified as no-response retry', async () => { + let calls = 0 + await withTransport(async () => { calls++; return { status: 502, headers: new Headers({ 'x-request-id': 'edge-request' }), + text: async () => { throw lost() } } }, async waits => { + const result = await invoke(context()) + assert.equal(calls, 1); assert.deepEqual(waits, []); assert.equal(result.http, 502) + assert.equal(result.requestId, 'edge-request'); assert.equal(result.transportAttempts[0].outcome, 'RESPONSE_READ_FAILED') + }) +}) + +test('unrecognized cause code containing sensitive material is omitted', async () => { + await withTransport(async () => { throw lost('secret-test-token https://private-host.invalid') }, async () => { + const result = await invoke(context()) + assert.ok(result.transportAttempts.every(item => item.causeCode === null)) + assert.doesNotMatch(JSON.stringify(result), /secret-test-token|private-host/) + }) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 93cd2d6af..720e549fe 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -108,20 +108,41 @@ export async function uploadEvidence(context, taskId, warehouseId, evidenceType, const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) const fileName = `${context.marker}-${String(fileSuffix).toLowerCase()}.png` form.append('file', new Blob([png], { type: 'image/png' }), fileName) - try { - const response = await fetch(`${context.options.gateway}${path}`, { - method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken || context.token}`, - 'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) }, - body: form, signal: AbortSignal.timeout(60_000), - }) - const raw = await response.text(); let payload = null - try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } - return { method: 'POST', path, http: response.status, body: payload, - raw: payload === null ? raw.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: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } + const url = `${context.options.gateway}${path}` + const headers = Object.freeze({ Accept: 'application/json', Authorization: `Bearer ${context.pdaToken || context.token}`, + 'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) }) + const attempts = [] + context.createdResources ??= {} + context.createdResources.loadingEvidenceTransport ??= [] + // No credentials, absolute endpoint or raw error/cause object enters the report. + context.createdResources.loadingEvidenceTransport.push({ path, fileName, + idempotencyKey: headers['Idempotency-Key'], attempts }) + for (let attempt = 1; attempt <= 3; attempt++) { + let response + try { + response = await fetch(url, { method: 'POST', headers, body: form, signal: AbortSignal.timeout(60_000) }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + const result = { method: 'POST', path, http: response.status, body: payload, + raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + attempts.push({ attempt, http: result.http, code: payload?.code ?? null, outcome: 'HTTP_RESPONSE', + requestId: result.requestId, traceId: result.traceId }) + return { ...result, transportAttempts: attempts } + } catch (error) { + const candidate = error?.cause?.code ?? error?.code + const causeCode = typeof candidate === 'string' && /^(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_[A-Z_]+)$/.test(candidate) + ? candidate : null + const errorType = ['TypeError', 'AbortError', 'TimeoutError'].includes(error?.name) ? error.name : 'TransportError' + const http = response?.status ?? 0 + attempts.push({ attempt, http, code: null, outcome: response ? 'RESPONSE_READ_FAILED' : 'NO_HTTP_RESPONSE', errorType, causeCode }) + if (response || attempt === 3) return { method: 'POST', path, http, body: null, + raw: `${errorType}: ${response ? 'response read failed' : 'fetch failed'}${causeCode ? ` (${causeCode})` : ''}`, + requestId: response?.headers?.get('x-request-id') || null, + traceId: response?.headers?.get('x-trace-id') || null, transportAttempts: attempts } + await new Promise(resolve => setTimeout(resolve, 250 * attempt)) + } } }