test(fleet): preserve PDF and direct fetch prerequisite evidence
This commit is contained in:
@@ -2,6 +2,7 @@ import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync
|
||||
import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
|
||||
import { redactEvidence } from './fba-core-regression-test-env.mjs'
|
||||
import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
|
||||
@@ -24,17 +25,35 @@ const success = response => response?.http === 200 && id(response?.body?.code) =
|
||||
const key = () => `fleet-${randomUUID()}`
|
||||
|
||||
/** Install before login/prerequisites. A caught unknown write must not permit another request. */
|
||||
export function guardFleetWrites(context, checkpoint) {
|
||||
export function guardFleetWrites(context, checkpoint, { guardFetch = false } = {}) {
|
||||
let stopped = null, sequence = 0
|
||||
for (const name of ['request', 'requestWithBody', 'requestBinary']) {
|
||||
const original = context[name]
|
||||
const scope = new AsyncLocalStorage(), originalFetch = globalThis.fetch
|
||||
const direct = { async requestWithBody(method, path, body, token, headers, request) {
|
||||
const nativeResponse = await originalFetch(request), copy = nativeResponse.clone()
|
||||
const content = await copy.text()
|
||||
let payload = null
|
||||
try { payload = content ? JSON.parse(content) : null } catch { /* Keep bounded diagnostic only. */ }
|
||||
return { nativeResponse, http: nativeResponse.status, body: payload, raw: payload ? null : content.slice(0, 500),
|
||||
requestId: payload?.requestId || nativeResponse.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || nativeResponse.headers.get('x-trace-id') }
|
||||
} }
|
||||
const targets = ['request', 'requestWithBody', 'requestBinary'].map(name => [context, name])
|
||||
if (guardFetch) targets.push([direct, 'requestWithBody'])
|
||||
for (const [owner, name] of targets) {
|
||||
const original = owner[name]
|
||||
if (typeof original !== 'function') continue
|
||||
context[name] = async function (method, path, body, token, headers) {
|
||||
owner[name] = async function (method, path, body, token, headers) {
|
||||
if (stopped) throw stopped
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(String(method).toUpperCase()))
|
||||
return original.apply(this, arguments)
|
||||
return scope.run(true, () => original.apply(this, arguments))
|
||||
const entry = { sequence: ++sequence, method, path, status: 'SENDING', startedAt: new Date().toISOString(),
|
||||
headers, body }
|
||||
let session = owner === direct ? String(headers?.authorization || '').replace(/^Bearer\s+/i, '')
|
||||
: token === undefined ? this.token : token
|
||||
if (owner !== direct && this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) session = this.pdaToken
|
||||
entry.actor = session && session === context.pdaToken ? { kind: 'PDA', source: 'CONTEXT_PDA_SESSION' }
|
||||
: session && session === context.token && context.options?.username ? { kind: 'PLATFORM', source: 'CONTEXT_ADMIN_SESSION' }
|
||||
: { kind: 'UNKNOWN', source: session ? 'UNRESOLVED_SESSION' : 'NO_SESSION' }
|
||||
// Multipart delegates to requestWithBody, so it is journaled exactly once, after construction.
|
||||
if (body instanceof FormData) {
|
||||
entry.body = { multipart: [] }
|
||||
@@ -44,31 +63,60 @@ export function guardFleetWrites(context, checkpoint) {
|
||||
}
|
||||
try { await checkpoint(entry) } catch (error) { stopped = error; throw error }
|
||||
try {
|
||||
const response = await original.apply(this, arguments)
|
||||
const response = await scope.run(true, () => original.apply(this, arguments))
|
||||
const http = response?.http, payload = response?.body, code = id(payload?.code)
|
||||
const binary = name === 'requestBinary' && response?.bytes instanceof Uint8Array
|
||||
const pdf = binary && http === 200 && /^application\/pdf(?:\s*;|$)/i.test(response.contentType || '')
|
||||
&& response.bytes.length > 4 && Buffer.from(response.bytes.subarray(0, 5)).toString('ascii') === '%PDF-'
|
||||
const definite = Number.isInteger(http) && http >= 200 && http < 500
|
||||
&& payload && typeof payload === 'object' && !Array.isArray(payload) && response.requestId && response.traceId
|
||||
&& (http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400))
|
||||
&& response.requestId && response.traceId
|
||||
&& (pdf || payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||
&& (name !== 'requestBinary' && http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400)))
|
||||
Object.assign(entry, { status: definite ? 'RESPONDED' : 'UNKNOWN_RESULT', http, code,
|
||||
requestId: response?.requestId, traceId: response?.traceId, finishedAt: new Date().toISOString() })
|
||||
requestId: response?.requestId, traceId: response?.traceId, raw: boundedFleetDiagnostic(response?.raw),
|
||||
responseBody: redactFleetWrite(response?.body), finishedAt: new Date().toISOString() })
|
||||
if (binary) entry.binary = { contentType: response.contentType, bytes: response.bytes.length,
|
||||
sha256: createHash('sha256').update(response.bytes).digest('hex'), validPdf: Boolean(pdf) }
|
||||
if (!definite) stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`)
|
||||
await checkpoint(entry)
|
||||
if (stopped) throw stopped
|
||||
return response
|
||||
} catch (error) {
|
||||
stopped ||= error
|
||||
stopped ||= Error(boundedFleetDiagnostic(error instanceof Error ? error.message : String(error)) || 'Fleet write failed')
|
||||
if (entry.status === 'SENDING') {
|
||||
entry.status = 'UNKNOWN_RESULT'; entry.finishedAt = new Date().toISOString()
|
||||
entry.raw = boundedFleetDiagnostic(error instanceof Error ? error.message : String(error))
|
||||
await checkpoint(entry)
|
||||
}
|
||||
throw stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
if (guardFetch) globalThis.fetch = async (input, init) => {
|
||||
if (scope.getStore()) return originalFetch(input, init)
|
||||
if (stopped) throw stopped
|
||||
const request = new Request(input, init), url = new URL(request.url)
|
||||
if (url.origin !== new URL(context.options.gateway).origin) return originalFetch(input, init)
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) return originalFetch(input, init)
|
||||
const type = request.headers.get('content-type') || ''
|
||||
let body
|
||||
if (request.body) body = type.includes('multipart/form-data') ? await request.clone().formData()
|
||||
: type.includes('application/json') ? await request.clone().json() : await request.clone().text()
|
||||
const result = await direct.requestWithBody(request.method, url.pathname + url.search, body, undefined,
|
||||
Object.fromEntries(request.headers), request)
|
||||
return result.nativeResponse
|
||||
}
|
||||
return () => { if (guardFetch) globalThis.fetch = originalFetch }
|
||||
}
|
||||
|
||||
function boundedFleetDiagnostic(value) {
|
||||
return typeof value === 'string' ? value.replace(/Bearer\s+[^\s"'<>]+/gi, 'Bearer [REDACTED]')
|
||||
.replace(/((?:password|accessToken|refreshToken|authorization)["']?\s*[=:]\s*["']?)[^\s,;"'<>]+/gi, '$1[REDACTED]')
|
||||
.replace(/https?:\/\/[^\s"'<>]+/gi, '[REDACTED_URL]').slice(0, 500) : null
|
||||
}
|
||||
|
||||
export function redactFleetWrite(value, secrets = []) {
|
||||
const sensitive = name => name !== 'draftToken' && /password|authorization|cookie|privateKey|token$|^(?:loginId|username)$/i.test(name)
|
||||
const sensitive = name => name !== 'draftToken' && /password|authorization|cookie|privateKey|api.?key|secret|token$|^(?:loginId|username)$/i.test(name)
|
||||
if (Array.isArray(value)) return value.map(item => redactFleetWrite(item, secrets))
|
||||
if (value && typeof value === 'object' && typeof value.field === 'string' && sensitive(value.field))
|
||||
return { field: value.field, value: '[REDACTED]' }
|
||||
@@ -531,7 +579,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw Error('Report scope invalid')
|
||||
mkdirSync(resolve(root, 'reports'), { recursive: true })
|
||||
const lockPath = resolve(root, 'reports/.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx')
|
||||
let context, initialized = false, report
|
||||
let context, initialized = false, report, restoreFetch
|
||||
const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(name)).map(([,value]) => value)
|
||||
const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, [...secrets,
|
||||
context?.token, context?.pdaToken, context?.refreshToken, context?.context?.merchantQualificationFixture?.password]), null, 2) + '\n')
|
||||
@@ -556,13 +604,13 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
||||
markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })
|
||||
context = createContext ? createContext(options) : new AcceptanceRun(options)
|
||||
let writeCheckpoint = 0
|
||||
guardFleetWrites(context, entry => {
|
||||
restoreFetch = guardFleetWrites(context, entry => {
|
||||
const payload = JSON.stringify(redactFleetWrite({ marker: context.marker, runId: config.runId, request: entry },
|
||||
[...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n'
|
||||
const descriptor = openSync(join(output, 'evidence', `write-${String(++writeCheckpoint).padStart(6, '0')}.json`), 'wx')
|
||||
try { writeFileSync(descriptor, payload); fsyncSync(descriptor) }
|
||||
finally { closeSync(descriptor) }
|
||||
})
|
||||
}, { guardFetch: true })
|
||||
context.context.fleetRunId = config.runId
|
||||
context.context.fleetScenario = config.scenario || 'handover'
|
||||
context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture)
|
||||
@@ -616,6 +664,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
||||
summary: context ? buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) : { failed: 1 },
|
||||
steps: context?.steps || [], modules: context?.modules || [], createdResources: context?.createdResources || {} }
|
||||
} finally {
|
||||
restoreFetch?.()
|
||||
if (initialized) save('report.json', report || { success: false, fatal: 'RUN_INTERRUPTED' })
|
||||
closeSync(lock); unlinkSync(lockPath)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user