87 lines
4.9 KiB
JavaScript
87 lines
4.9 KiB
JavaScript
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/)
|
|
})
|
|
})
|