diff --git a/esure-backend/package.json b/esure-backend/package.json index 0837699..c56979b 100644 --- a/esure-backend/package.json +++ b/esure-backend/package.json @@ -20,6 +20,14 @@ "types": "./dist/scenario-schema.d.ts", "default": "./dist/scenario-schema.js" }, + "./report-schema": { + "types": "./dist/report-schema.d.ts", + "default": "./dist/report-schema.js" + }, + "./report-schema.js": { + "types": "./dist/report-schema.d.ts", + "default": "./dist/report-schema.js" + }, "./scenario-loader": { "types": "./dist/scenario-loader.d.ts", "default": "./dist/scenario-loader.js" diff --git a/esure-backend/src/app.ts b/esure-backend/src/app.ts index 34ab634..7ec752a 100644 --- a/esure-backend/src/app.ts +++ b/esure-backend/src/app.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import Fastify, { type FastifyInstance } from "fastify"; +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; export type AppInstance = FastifyInstance & { runs: RunService }; export async function closeAfterDrain(app: { close: () => Promise; runs: Pick }, timeoutMs: number): Promise { @@ -43,6 +43,38 @@ const runBodySchema = { }, } as const; +const scenarioIdParamsSchema = { + type: "object", + additionalProperties: false, + required: ["scenarioId"], + properties: { scenarioId: { type: "string", pattern: "^[a-z0-9-]{1,64}$" } }, +} as const; + +const versionParamsSchema = { + type: "object", + additionalProperties: false, + required: ["scenarioId", "version"], + properties: { + scenarioId: { type: "string", pattern: "^[a-z0-9-]{1,64}$" }, + version: { type: "string", pattern: "^[1-9][0-9]{0,9}$" }, + }, +} as const; + +const historyQuerySchema = { + type: "object", + additionalProperties: false, + properties: { + limit: { type: "string", pattern: "^[1-9][0-9]{0,2}$" }, + cursor: { type: "string", pattern: "^[1-9][0-9]{0,9}$" }, + }, +} as const; + +const exportQuerySchema = { + type: "object", + additionalProperties: false, + properties: { format: { type: "string", enum: ["json", "yaml"] } }, +} as const; + /** Maximum Idempotency-Key length accepted (issue #72). */ const IDEMPOTENCY_KEY_MAX_LENGTH = 128; @@ -56,6 +88,7 @@ const IDEMPOTENCY_MAX_ENTRIES = 1_000; const IDEMPOTENCY_RETENTION_MS = 86_400_000; export function buildApp(options: BuildAppOptions): AppInstance { + const clock = options.clock ?? Date.now; const app = Fastify({ logger: options.logger ?? false, bodyLimit: options.config.bodyLimitBytes, ajv: { customOptions: { removeAdditional: false } } }); app.addContentTypeParser(["application/yaml", "application/x-yaml", "text/yaml"], { parseAs: "string" }, (_request, body, done) => done(null, body)); // Issue #77: fastify's default text/plain parser would otherwise accept @@ -89,7 +122,7 @@ export function buildApp(options: BuildAppOptions): AppInstance { const ledger = options.ledger ?? new StellarTestnetGateway(options.config.horizonUrl, options.config.friendbotUrl, { fundingConcurrency: options.config.maxConcurrentFriendbotRequests, }); - const runs = new RunService(persistence.runStore ?? new InMemoryRunStore(options.config.maxStoredRuns, options.config.runRetentionMs), ledger, { + const runs = new RunService(persistence.runStore ?? new InMemoryRunStore(options.config.maxStoredRuns, options.config.runRetentionMs, clock), ledger, { maxConcurrentRuns: options.config.maxConcurrentRuns, runTimeoutMs: options.config.runTimeoutMs, stepTimeoutMs: options.config.stepTimeoutMs, }, clock); @@ -235,13 +268,14 @@ export function buildApp(options: BuildAppOptions): AppInstance { app.get<{ Params: { runId: string } }>("/api/v1/runs/:runId", async (request, reply) => { const run = await runs.get(request.params.runId); if (!run) return reply.code(404).send(apiError("RUN_NOT_FOUND", "The requested run does not exist.", request.id)); - return withRetentionMetadata(run, options.config.runRetentionMs); + return sendConditionalRunReport(request, reply, withRetentionMetadata(run, options.config.runRetentionMs)); }); app.get<{ Params: { runId: string } }>("/api/v1/runs/:runId/report", async (request, reply) => { const run = await runs.get(request.params.runId); if (!run) return reply.code(404).send(apiError("RUN_NOT_FOUND", "The requested run does not exist.", request.id)); if (run.status !== "passed" && run.status !== "failed") return reply.code(409).send(apiError("RUN_NOT_COMPLETE", "The requested run is not complete.", request.id)); - return reply.header("content-disposition", `attachment; filename=esure-run-${run.id}.json`).send(withRetentionMetadata(run, options.config.runRetentionMs)); + reply.header("content-disposition", `attachment; filename=esure-run-${run.id}.json`); + return sendConditionalRunReport(request, reply, withRetentionMetadata(run, options.config.runRetentionMs)); }); app.setErrorHandler((error, request, reply) => { @@ -284,6 +318,28 @@ function catalogueETag(items: { id: string; version: number; contentHash: string return `"${digest}"`; } +function sendConditionalRunReport( + request: FastifyRequest, + reply: FastifyReply, + report: RunReport, +) { + const body = JSON.stringify(report); + const etag = `"${createHash("sha256").update(body).digest("hex")}"`; + reply.header("ETag", etag); + reply.header("Cache-Control", "no-cache"); + + const ifNoneMatchHeader = request.headers["if-none-match"]; + const ifNoneMatch = Array.isArray(ifNoneMatchHeader) ? ifNoneMatchHeader.join(",") : ifNoneMatchHeader; + if (ifNoneMatch && ifNoneMatch.split(",").some((candidate) => { + const tag = candidate.trim(); + return tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === etag; + })) { + return reply.code(304).send(); + } + + return reply.type("application/json").send(body); +} + // ── Issue #72: idempotency helpers ───────────────────────────────────────── function extractIdempotencyKey(header: unknown): string | undefined { if (typeof header !== "string") return undefined; diff --git a/esure-backend/src/domain.ts b/esure-backend/src/domain.ts index 746b2c3..a115857 100644 --- a/esure-backend/src/domain.ts +++ b/esure-backend/src/domain.ts @@ -119,6 +119,7 @@ export interface RunReport { status: RunStatus; createdAt: string; completedAt?: string; + expiresAt?: string; steps: StepResult[]; assertions: AssertionResult[]; summary: RunSummary; diff --git a/esure-backend/src/openapi.ts b/esure-backend/src/openapi.ts index 78ce4e9..8c0a0af 100644 --- a/esure-backend/src/openapi.ts +++ b/esure-backend/src/openapi.ts @@ -129,17 +129,41 @@ export function openApiDocument() { "Active reports never include `expiresAt` — a running run has no terminal expiry to claim.", "`expiresAt` is a best-effort upper bound, not a guarantee: the in-memory run store may evict a", "terminal run earlier under capacity pressure, and no run state survives a process restart.", + "Supports conditional GET with `If-None-Match`; a matching report returns 304 without a body.", + "Responses include an ETag for the complete representation and `Cache-Control: no-cache`,", + "so active reports are revalidated rather than treated as immutable.", ].join(" "), parameters: [{ name: "runId", in: "path", required: true, schema: { type: "string", format: "uuid" } }], - responses: { "200": { description: "Run report" }, "404": { description: "Not found" } }, + responses: { + "200": { + description: "Run report", + headers: { + "ETag": { schema: { type: "string" }, description: "Fingerprint of the complete run representation; changes when any report field changes." }, + "Cache-Control": { schema: { type: "string" }, description: "no-cache requires revalidation before reuse, including for active reports." }, + }, + }, + "304": { description: "Run report unchanged; no response body." }, + "404": { description: "Not found" }, + }, }, }, "/api/v1/runs/{runId}/report": { get: { operationId: "downloadRunReport", - description: "Final JSON report for a terminal run, including the same best-effort `expiresAt` retention hint as GET /api/v1/runs/{runId} (issue #76).", + description: "Final JSON report for a terminal run, including the same best-effort `expiresAt` retention hint and ETag revalidation behavior as GET /api/v1/runs/{runId}.", parameters: [{ name: "runId", in: "path", required: true, schema: { type: "string", format: "uuid" } }], - responses: { "200": { description: "Final JSON report" }, "409": { description: "Run incomplete" } }, + responses: { + "200": { + description: "Final JSON report", + headers: { + "ETag": { schema: { type: "string" }, description: "Fingerprint of the complete report representation." }, + "Cache-Control": { schema: { type: "string" }, description: "no-cache requires revalidation before reuse." }, + }, + }, + "304": { description: "Report unchanged; no response body." }, + "409": { description: "Run incomplete" }, + "404": { description: "Not found" }, + }, }, }, }, diff --git a/esure-backend/src/report-schema.ts b/esure-backend/src/report-schema.ts new file mode 100644 index 0000000..4a5f808 --- /dev/null +++ b/esure-backend/src/report-schema.ts @@ -0,0 +1,147 @@ +import { Ajv } from "ajv"; +import type { RunReport } from "./domain.js"; + +const jsonValue = { + anyOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + { type: "null" }, + { type: "array", maxItems: 1000, items: { $ref: "#/$defs/jsonValue" } }, + { type: "object", maxProperties: 1000, additionalProperties: { $ref: "#/$defs/jsonValue" } }, + ], +} as const; + +export const runReportSchemaV1 = { + $id: "https://esure.dev/schemas/run-report-v1.json", + type: "object", + additionalProperties: false, + required: [ + "id", "scenarioId", "scenarioVersion", "scenarioSchemaVersion", "scenarioContentHash", + "network", "status", "createdAt", "steps", "assertions", "summary", + ], + properties: { + id: { type: "string", minLength: 1 }, + scenarioId: { type: "string", minLength: 1, maxLength: 64 }, + scenarioVersion: { type: "integer", minimum: 1, maximum: 2_147_483_647 }, + scenarioSchemaVersion: { const: 1 }, + scenarioContentHash: { type: "string", minLength: 1 }, + network: { const: "testnet" }, + status: { enum: ["requested", "validating", "running", "passed", "failed"] }, + createdAt: { type: "string", minLength: 1 }, + completedAt: { type: "string", minLength: 1 }, + expiresAt: { type: "string", minLength: 1 }, + steps: { + type: "array", + maxItems: 1000, + items: { + type: "object", + additionalProperties: false, + required: ["id", "type", "status", "message"], + properties: { + id: { type: "string", minLength: 1 }, + type: { type: "string", minLength: 1 }, + status: { enum: ["passed", "failed"] }, + transactionHash: { type: "string" }, + ledger: { type: "integer", minimum: 1 }, + feeCharged: { type: "string", pattern: "^(0|[1-9][0-9]*)$" }, + stellarTransactionCode: { type: "string" }, + stellarOperationCodes: { type: "array", maxItems: 100, items: { type: "string" } }, + message: { type: "string" }, + startedAt: { type: "string", minLength: 1 }, + completedAt: { type: "string", minLength: 1 }, + durationMs: { type: "number", minimum: 0 }, + }, + }, + }, + assertions: { + type: "array", + maxItems: 1000, + items: { + type: "object", + additionalProperties: false, + required: ["type", "status", "expected", "actual", "message"], + properties: { + type: { + enum: [ + "transactionMemoEquals", "balanceEquals", "balanceChangedBy", "stepSucceeded", + "transactionConfirmed", "stepFailedWith", "trustlineExists", "trustlineMissing", + "trustlineLimitEquals", "accountExists", "accountMissing", "feeAwareNativeBalanceDelta", + ], + }, + status: { enum: ["passed", "failed"] }, + expected: jsonValue, + actual: jsonValue, + message: { type: "string" }, + }, + }, + }, + summary: { + type: "object", + additionalProperties: false, + required: ["stepsPassed", "stepsFailed", "assertionsPassed", "assertionsFailed"], + properties: { + stepsPassed: { type: "integer", minimum: 0 }, + stepsFailed: { type: "integer", minimum: 0 }, + assertionsPassed: { type: "integer", minimum: 0 }, + assertionsFailed: { type: "integer", minimum: 0 }, + }, + }, + error: { + type: "object", + additionalProperties: false, + required: ["code", "message", "category", "retryable"], + properties: { + code: { type: "string", minLength: 1 }, + message: { type: "string" }, + category: { enum: ["stellar", "network", "timeout", "capacity", "validation", "internal"] }, + retryable: { type: "boolean" }, + failedStepId: { type: "string" }, + stellarTransactionCode: { type: "string" }, + stellarOperationCodes: { type: "array", maxItems: 100, items: { type: "string" } }, + }, + }, + }, + $defs: { jsonValue }, +} as const; + +const ajv = new Ajv({ allErrors: true, strict: true }); +const validateSchema = ajv.compile(runReportSchemaV1); + +export class RunReportValidationError extends Error { + constructor(readonly issues: string[]) { + super("Run report is invalid."); + this.name = "RunReportValidationError"; + } +} + +export function validateRunReport(value: unknown): RunReport { + if (!validateSchema(value)) { + throw new RunReportValidationError( + (validateSchema.errors ?? []).map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`), + ); + } + + const report = value; + const timestampIssues: string[] = []; + for (const [field, timestamp] of [ + ["createdAt", report.createdAt], + ["completedAt", report.completedAt], + ["expiresAt", report.expiresAt], + ] as const) { + if (timestamp !== undefined && !isIsoDateTime(timestamp)) timestampIssues.push(`/${field} must be a valid ISO date-time`); + } + for (const [index, step] of report.steps.entries()) { + for (const field of ["startedAt", "completedAt"] as const) { + const timestamp = step[field]; + if (timestamp !== undefined && !isIsoDateTime(timestamp)) timestampIssues.push(`/steps/${index}/${field} must be a valid ISO date-time`); + } + } + if (timestampIssues.length) throw new RunReportValidationError(timestampIssues); + return report; +} + +function isIsoDateTime(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) + && Number.isFinite(Date.parse(value)); +} diff --git a/esure-backend/test/app.test.ts b/esure-backend/test/app.test.ts index ca83607..43c9402 100644 --- a/esure-backend/test/app.test.ts +++ b/esure-backend/test/app.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildApp } from "../src/app.js"; import { closeAfterDrain, type AppInstance } from "../src/app.js"; -import type { LedgerExecution, LedgerGateway, Scenario } from "../src/domain.js"; +import type { LedgerExecution, LedgerExecutionOptions, LedgerGateway, Scenario } from "../src/domain.js"; import type { AppConfig } from "../src/config.js"; import { SafeRunError } from "../src/errors.js"; import { DatabaseUnavailableError } from "../src/database.js"; @@ -370,14 +370,105 @@ describe("Esure API", () => { const first = await app.inject({ method: "POST", url: "/api/v1/runs", payload: { scenarioId: "xlm-payment" } }); const firstId = first.json().id as string; await vi.waitFor(async () => expect((await app.inject({ method: "GET", url: `/api/v1/runs/${firstId}` })).json().status).toBe("passed")); + const firstRead = await app.inject({ method: "GET", url: `/api/v1/runs/${firstId}` }); // Starting a second run evicts the first terminal run once the store is at capacity. await app.inject({ method: "POST", url: "/api/v1/runs", payload: { scenarioId: "xlm-payment" } }); - const evicted = await app.inject({ method: "GET", url: `/api/v1/runs/${firstId}` }); + const evicted = await app.inject({ + method: "GET", + url: `/api/v1/runs/${firstId}`, + headers: { "if-none-match": firstRead.headers.etag as string }, + }); expect(evicted.statusCode).toBe(404); expect(evicted.json().error.code).toBe("RUN_NOT_FOUND"); }); }); + describe("conditional run report reads (#163)", () => { + it("returns a revalidation ETag and an empty 304 for an unchanged existing report", async () => { + const app = createApp(); + const started = await app.inject({ method: "POST", url: "/api/v1/runs", payload: { scenarioId: "xlm-payment" } }); + const id = started.json().id as string; + await vi.waitFor(async () => { + const response = await app.inject({ method: "GET", url: `/api/v1/runs/${id}` }); + expect(response.json().status).toBe("passed"); + }); + + const first = await app.inject({ method: "GET", url: `/api/v1/runs/${id}` }); + const etag = first.headers.etag as string; + expect(etag).toMatch(/^"[a-f0-9]{64}"$/); + expect(first.headers["cache-control"]).toBe("no-cache"); + expect(first.statusCode).toBe(200); + + const unchanged = await app.inject({ + method: "GET", + url: `/api/v1/runs/${id}`, + headers: { "if-none-match": `"stale", W/${etag}` }, + }); + expect(unchanged.statusCode).toBe(304); + expect(unchanged.body).toBe(""); + + const downloaded = await app.inject({ method: "GET", url: `/api/v1/runs/${id}/report` }); + const downloadEtag = downloaded.headers.etag as string; + expect(downloaded.statusCode).toBe(200); + const unchangedDownload = await app.inject({ + method: "GET", + url: `/api/v1/runs/${id}/report`, + headers: { "if-none-match": downloadEtag }, + }); + expect(unchangedDownload.statusCode).toBe(304); + expect(unchangedDownload.body).toBe(""); + }); + + it("changes the ETag when evidence or terminal status changes", async () => { + let resolveExecution!: (execution: LedgerExecution) => void; + let executionOptions: LedgerExecutionOptions | undefined; + const activeLedger: LedgerGateway = { + execute: vi.fn((_scenario, options) => { + executionOptions = options; + return new Promise((resolve) => { resolveExecution = resolve; }); + }), + }; + const app = createApp({ ledger: activeLedger }); + const started = await app.inject({ method: "POST", url: "/api/v1/runs", payload: { scenarioId: "xlm-payment" } }); + const id = started.json().id as string; + await vi.waitFor(() => expect(activeLedger.execute).toHaveBeenCalled()); + + const initial = await app.inject({ method: "GET", url: `/api/v1/runs/${id}` }); + const initialTag = initial.headers.etag as string; + expect(initial.json().steps).toEqual([]); + + const step = { id: "send-xlm", type: "payment", status: "passed" as const, message: "Confirmed." }; + executionOptions?.onStep?.(step); + let evidenceRead = initial; + await vi.waitFor(async () => { + evidenceRead = await app.inject({ method: "GET", url: `/api/v1/runs/${id}` }); + expect(evidenceRead.json().steps).toEqual([step]); + }); + const evidenceTag = evidenceRead.headers.etag as string; + expect(evidenceTag).not.toBe(initialTag); + expect((await app.inject({ + method: "GET", + url: `/api/v1/runs/${id}`, + headers: { "if-none-match": initialTag }, + })).statusCode).toBe(200); + + resolveExecution({ steps: [step], assertions: [] }); + let terminalRead = evidenceRead; + await vi.waitFor(async () => { + terminalRead = await app.inject({ method: "GET", url: `/api/v1/runs/${id}` }); + expect(terminalRead.json().status).toBe("passed"); + }); + expect(terminalRead.headers.etag).not.toBe(evidenceTag); + const terminalTag = terminalRead.headers.etag as string; + const unchangedTerminal = await app.inject({ + method: "GET", + url: `/api/v1/runs/${id}`, + headers: { "if-none-match": terminalTag }, + }); + expect(unchangedTerminal.statusCode).toBe(304); + }); + }); + // ── Issue #77: unsupported content type ────────────────────────────────── describe("content type handling", () => { it.each([ diff --git a/esure-cli/README.md b/esure-cli/README.md index f5aa4dd..6e5e586 100644 --- a/esure-cli/README.md +++ b/esure-cli/README.md @@ -25,6 +25,17 @@ npx esure validate ./my-scenario.yaml npx esure validate ./my-scenario.json ``` +### Validate an archived JSON report offline + +```bash +npx esure report validate ./run-report.json +npx esure report validate ./run-report.json --output json +``` + +This command reads at most 5 MiB, checks the backend's maintained run-report +contract, and never executes imported content or makes network requests. +`--output json` writes successful results to stdout and failures to stderr. + ### Run a bundled scenario ```bash @@ -113,6 +124,19 @@ Validates a scenario file without executing it. - `0`: Valid scenario - `1`: Invalid scenario or validation error +### `esure report validate ` + +Validates an archived JSON run report against the backend's maintained report +contract without network access or scenario execution. Files larger than 5 MiB +are rejected. + +**Options:** +- `--output json`: Output the validation result in JSON format + +**Exit codes:** +- `0`: Valid report +- `1`: Invalid, unreadable, unsupported, or oversized report + ### `esure run ` Executes a scenario on Stellar Testnet. diff --git a/esure-cli/src/cli.ts b/esure-cli/src/cli.ts index 1b68d4e..3cdb1ba 100644 --- a/esure-cli/src/cli.ts +++ b/esure-cli/src/cli.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node -import { access, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { access, open, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { basename, dirname, extname, resolve } from "node:path"; import { parseArgs } from "node:util"; import { validateScenarioDefinition } from "@esure/backend/scenario-schema.js"; +import { RunReportValidationError, validateRunReport } from "@esure/backend/report-schema.js"; import { prepareScenario } from "@esure/backend/scenario-loader.js"; import { createScenarioRegistry } from "@esure/backend/scenarios.js"; import { StellarTestnetGateway } from "@esure/backend/stellar-gateway.js"; @@ -13,6 +14,7 @@ import type { LedgerExecution, LedgerExecutionOptions } from "@esure/backend/dom const VERSION = "0.1.0"; const BUNDLED_SCENARIO_DIR_TIMEOUT_MS = 30_000; +const MAX_REPORT_FILE_BYTES = 5 * 1024 * 1024; // ───────────────────────────────────────────────────────────────────────────── // Shared types @@ -62,6 +64,7 @@ esure - Stellar Testnet scenario testing toolkit Usage: esure list List bundled scenarios esure validate Validate a scenario file + esure report validate Validate an archived JSON run report offline esure init Scaffold a new scenario file (.json, .yaml, .yml) esure run Run a bundled scenario esure run Run a scenario from file @@ -82,6 +85,7 @@ Examples: esure list esure init ./my-scenario.yaml esure validate ./my-scenario.yaml + esure report validate ./report.json --output json esure run xlm-payment esure run ./my-scenario.yaml --output json --report-file report.json esure run ./my-scenario.yaml --junit junit.xml @@ -107,8 +111,8 @@ function parseCliArgs(): { failFast: boolean; parseError?: string; } { - let values: Record; - let positionals: string[]; + let parsedValues: Record; + let parsedPositionals: string[]; try { const parsed = parseArgs({ @@ -118,12 +122,18 @@ function parseCliArgs(): { version: { type: "boolean", short: "v" }, ascii: { type: "boolean" }, "fail-fast": { type: "boolean" }, + "report-file": { type: "string" }, + junit: { type: "string" }, + "junit-file": { type: "string" }, + csv: { type: "string" }, + overwrite: { type: "boolean" }, + force: { type: "boolean" }, }, allowPositionals: true, strict: true, }); - values = parsed.values; - positionals = parsed.positionals; + parsedValues = parsed.values; + parsedPositionals = parsed.positionals; } catch (err: any) { return { command: undefined, @@ -134,13 +144,13 @@ function parseCliArgs(): { }; } - if (values.output !== undefined && values.output !== "human" && values.output !== "json") { + if (parsedValues.output !== undefined && parsedValues.output !== "human" && parsedValues.output !== "json") { return { - command: positionals[0], - args: positionals.slice(1), + command: parsedPositionals[0], + args: parsedPositionals.slice(1), options: { output: "human", help: false, version: false, ascii: false }, failFast: false, - parseError: `Invalid output format "${values.output}". Supported formats: human, json`, + parseError: `Invalid output format "${parsedValues.output}". Supported formats: human, json`, }; } const { values, positionals } = parseArgs({ @@ -605,6 +615,83 @@ async function validateCommand(filePath: string, options: CliOptions): Promise { + const handle = await open(resolve(filePath), "r"); + try { + const { size } = await handle.stat(); + if (size > MAX_REPORT_FILE_BYTES) { + throw new Error(`Report file exceeds the maximum size of ${MAX_REPORT_FILE_BYTES / (1024 * 1024)} MiB.`); + } + + const buffer = Buffer.alloc(MAX_REPORT_FILE_BYTES + 1); + let totalBytes = 0; + while (totalBytes < buffer.length) { + const { bytesRead } = await handle.read( + buffer, + totalBytes, + Math.min(64 * 1024, buffer.length - totalBytes), + totalBytes, + ); + if (bytesRead === 0) break; + totalBytes += bytesRead; + } + if (totalBytes > MAX_REPORT_FILE_BYTES) { + throw new Error(`Report file exceeds the maximum size of ${MAX_REPORT_FILE_BYTES / (1024 * 1024)} MiB.`); + } + + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, totalBytes)); + } catch { + throw new Error("Report file is not valid UTF-8."); + } + } finally { + await handle.close(); + } +} + +async function validateReportCommand(filePath: string, options: CliOptions): Promise { + try { + if (extname(filePath).toLowerCase() !== ".json") { + throw new Error("Report file must have a .json extension."); + } + + const content = await readBoundedReportFile(filePath); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (error) { + const detail = error instanceof Error ? error.message : "invalid JSON"; + throw new Error(`Report file contains invalid JSON: ${detail}`); + } + + const report = validateRunReport(parsed); + if (options.output === "json") { + console.log(JSON.stringify({ + valid: true, + reportId: report.id, + scenarioId: report.scenarioId, + status: report.status, + }, null, 2)); + } else { + console.log("✓ Report is valid"); + console.log(` ID: ${report.id}`); + console.log(` Scenario: ${report.scenarioId}`); + console.log(` Status: ${report.status}`); + } + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const issues = error instanceof RunReportValidationError ? error.issues : undefined; + if (options.output === "json") { + console.error(JSON.stringify({ valid: false, error: { message, ...(issues ? { issues } : {}) } }, null, 2)); + } else { + console.error(`✗ Report validation failed: ${message}`); + for (const issue of issues ?? []) console.error(` - ${issue}`); + } + return 1; + } +} + async function runCommand(target: string, options: CliOptions): Promise { let scenario: ValidatedScenario | undefined; let execution: LedgerExecution | undefined; @@ -1027,6 +1114,12 @@ async function main(): Promise { return 1; } return validateCommand(args[0], options); + case "report": + if (args[0] !== "validate" || !args[1] || args.length > 2) { + console.error("Usage: esure report validate [--output human|json]"); + return 1; + } + return validateReportCommand(args[1], options); case "init": if (args.length === 0) { console.error("Error: Missing file path argument"); diff --git a/esure-cli/test/report-validation.test.ts b/esure-cli/test/report-validation.test.ts new file mode 100644 index 0000000..2434bb4 --- /dev/null +++ b/esure-cli/test/report-validation.test.ts @@ -0,0 +1,116 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const cliPath = resolve(__dirname, "../dist/cli.js"); + +const validReport = { + id: "run-123", + scenarioId: "xlm-payment", + scenarioVersion: 1, + scenarioSchemaVersion: 1, + scenarioContentHash: "sha256:scenario", + network: "testnet", + status: "passed", + createdAt: "2026-10-16T00:00:00.000Z", + completedAt: "2026-10-16T00:00:10.000Z", + expiresAt: "2026-10-17T00:00:10.000Z", + steps: [{ + id: "payment", + type: "payment", + status: "passed", + message: "Payment confirmed.", + feeCharged: "100", + }], + assertions: [{ + type: "balanceEquals", + status: "passed", + expected: "12.3400000", + actual: "12.3400000", + message: "Balance matches.", + }], + summary: { stepsPassed: 1, stepsFailed: 0, assertionsPassed: 1, assertionsFailed: 0 }, +}; + +describe("CLI offline report validation (#156)", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdtemp(join(tmpdir(), "esure-report-validation-")); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function validateFile(name: string, contents: string, output = "human") { + const filePath = join(tempDir, name); + await writeFile(filePath, contents, "utf-8"); + return execFileAsync(process.execPath, [cliPath, "report", "validate", filePath, "--output", output], { + cwd: tempDir, + }); + } + + it("accepts a valid report offline and preserves decimal-string values", async () => { + const { stdout } = await validateFile("valid.json", JSON.stringify(validReport), "json"); + expect(JSON.parse(stdout)).toEqual({ + valid: true, + reportId: "run-123", + scenarioId: "xlm-payment", + status: "passed", + }); + expect(validReport.assertions[0].expected).toBe("12.3400000"); + }); + + it("accepts older reports without optional expiry metadata", async () => { + const { stdout } = await validateFile( + "without-expiry.json", + JSON.stringify(Object.fromEntries(Object.entries(validReport).filter(([key]) => key !== "expiresAt"))), + "json", + ); + expect(JSON.parse(stdout)).toMatchObject({ valid: true, reportId: "run-123" }); + }); + + it.each([ + ["malformed JSON", "{" , "invalid JSON"], + ["invalid schema", JSON.stringify({ ...validReport, network: "mainnet" }), "/network"], + ["invalid assertion status", JSON.stringify({ + ...validReport, + assertions: [{ ...validReport.assertions[0], status: "skipped" }], + }), "/assertions/0/status"], + ["invalid expiry timestamp", JSON.stringify({ ...validReport, expiresAt: "not-a-date" }), "/expiresAt"], + ])("rejects %s with a clear JSON error", async (_label, contents, expectedMessage) => { + try { + await validateFile("invalid.json", contents, "json"); + expect.unreachable("invalid report should return exit code 1"); + } catch (error) { + expect(error).toMatchObject({ code: 1 }); + expect((error as { stderr: string }).stderr).toContain(expectedMessage); + } + }); + + it("rejects input larger than 5 MiB before parsing", async () => { + const oversized = `${JSON.stringify(validReport)}${" ".repeat(5 * 1024 * 1024)}`; + try { + await validateFile("oversized.json", oversized, "json"); + expect.unreachable("oversized report should return exit code 1"); + } catch (error) { + expect(error).toMatchObject({ code: 1 }); + expect((error as { stderr: string }).stderr).toContain("exceeds the maximum size"); + } + }); + + it("rejects unsupported file extensions", async () => { + try { + await validateFile("report.yaml", JSON.stringify(validReport), "json"); + expect.unreachable("unsupported extension should return exit code 1"); + } catch (error) { + expect(error).toMatchObject({ code: 1 }); + expect((error as { stderr: string }).stderr).toContain(".json extension"); + } + }); +}); diff --git a/esure-frontend/src/app/globals.css b/esure-frontend/src/app/globals.css index 3358945..c4bdeeb 100644 --- a/esure-frontend/src/app/globals.css +++ b/esure-frontend/src/app/globals.css @@ -91,6 +91,8 @@ button { color:inherit; } .timeline-card,.summary-card { border:1px solid var(--line); background:#f8f6f0; } .report-meta { padding:18px 24px; display:grid; grid-template-columns:auto 1fr auto auto; gap:12px; border-bottom:1px solid var(--line); align-items:center; } .report-meta span { font:500 9px var(--font-mono); color:var(--muted); letter-spacing:.12em; }.report-meta code { font:500 10px var(--font-mono); overflow:hidden; text-overflow:ellipsis; } +.report-expiration-hint { margin:0; padding:12px 24px; color:var(--muted); font-size:12px; line-height:1.5; border-bottom:1px solid var(--line); } +.browser-offline-banner { margin:0 0 16px; padding:12px 16px; color:var(--ink); background:#f5edd6; border:1px solid #d4c59b; font-size:13px; line-height:1.5; } .id-cell { display:flex; align-items:center; gap:8px; min-width:0; } .copy-control { display:inline-flex; align-items:center; gap:6px; } .copy-button { border:1px solid var(--line); background:transparent; color:var(--deep); font:600 10px var(--font-mono); letter-spacing:.06em; text-transform:uppercase; padding:5px 8px; cursor:pointer; border-radius:2px; } diff --git a/esure-frontend/src/components/dashboard.test.tsx b/esure-frontend/src/components/dashboard.test.tsx index 6b29568..9c6a2ae 100644 --- a/esure-frontend/src/components/dashboard.test.tsx +++ b/esure-frontend/src/components/dashboard.test.tsx @@ -91,6 +91,11 @@ function changeDocumentVisibility(state: "visible" | "hidden") { document.dispatchEvent(new Event("visibilitychange")); } +function changeConnectionState(online: boolean) { + Object.defineProperty(navigator, "onLine", { configurable: true, value: online }); + window.dispatchEvent(new Event(online ? "online" : "offline")); +} + describe("filterScenario helper", () => { it("includes all scenarios when filter is 'all'", () => { mockScenarios.forEach((scenario) => { @@ -98,6 +103,7 @@ describe("filterScenario helper", () => { }); }); + it("filters XLM scenarios correctly", () => { expect(filterScenario(mockScenarios[0], "xlm")).toBe(true); expect(filterScenario(mockScenarios[1], "xlm")).toBe(false); @@ -439,6 +445,7 @@ describe("Dashboard Sequential Polling and Recovery", () => { vi.useRealTimers(); cleanup(); Reflect.deleteProperty(document, "visibilityState"); + Reflect.deleteProperty(navigator, "onLine"); vi.restoreAllMocks(); }); @@ -522,6 +529,65 @@ describe("Dashboard Sequential Polling and Recovery", () => { expect(getRunMock).toHaveBeenCalledTimes(3); }); + it("pauses polling offline and performs one immediate refresh when reconnected", async () => { + const getRunMock = vi.spyOn(api, "getRun") + .mockResolvedValueOnce({ ...mockRun, status: "pending" }) + .mockResolvedValueOnce({ ...mockRun, status: "pending", createdAt: "2026-01-01T00:00:00.000Z" }) + .mockResolvedValueOnce({ ...mockRun, status: "pending", createdAt: "2026-01-02T00:00:00.000Z" }); + + render(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(getRunMock).toHaveBeenCalledTimes(1); + + act(() => { changeConnectionState(false); }); + const offlineMessage = screen.getByText(/You are offline/); + expect(offlineMessage.getAttribute("role")).toBe("status"); + + await act(async () => { vi.advanceTimersByTime(5000); await Promise.resolve(); }); + expect(getRunMock).toHaveBeenCalledTimes(1); + expect(screen.getByText("run-123")).toBeDefined(); + + await act(async () => { changeConnectionState(true); await Promise.resolve(); }); + expect(getRunMock).toHaveBeenCalledTimes(2); + expect(screen.queryByText(/You are offline/)).toBeNull(); + + await act(async () => { vi.advanceTimersByTime(999); await Promise.resolve(); }); + expect(getRunMock).toHaveBeenCalledTimes(2); + await act(async () => { vi.advanceTimersByTime(1); await Promise.resolve(); }); + expect(getRunMock).toHaveBeenCalledTimes(3); + }); + + it("aborts an in-flight poll when offline and does not apply its stale result", async () => { + let resolveInFlight!: (value: RunReport) => void; + let inFlightSignal: AbortSignal | undefined; + const getRunMock = vi.spyOn(api, "getRun") + .mockResolvedValueOnce({ ...mockRun, status: "pending" }) + .mockImplementationOnce((_runId, signal) => { + inFlightSignal = signal; + return new Promise((resolve) => { resolveInFlight = resolve; }); + }) + .mockResolvedValueOnce({ ...mockRun, status: "passed" }); + + render(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + await act(async () => { vi.advanceTimersByTime(1000); await Promise.resolve(); }); + expect(getRunMock).toHaveBeenCalledTimes(2); + + act(() => { changeConnectionState(false); }); + expect(inFlightSignal?.aborted).toBe(true); + + await act(async () => { + resolveInFlight({ ...mockRun, id: "stale-run", status: "failed" }); + await Promise.resolve(); + }); + expect(screen.queryByText("stale-run")).toBeNull(); + expect(screen.getByText("run-123")).toBeDefined(); + + await act(async () => { changeConnectionState(true); await Promise.resolve(); }); + expect(getRunMock).toHaveBeenCalledTimes(3); + expect(screen.getAllByText("Passed").length).toBeGreaterThan(0); + }); + it("aborts an in-flight refresh on hide and ignores its stale result", async () => { let resolveInFlight!: (value: RunReport) => void; let inFlightSignal: AbortSignal | undefined; @@ -561,16 +627,24 @@ describe("Dashboard Sequential Polling and Recovery", () => { it("removes the visibility listener and scheduled timer on unmount", async () => { const addListener = vi.spyOn(document, "addEventListener"); const removeListener = vi.spyOn(document, "removeEventListener"); + const addWindowListener = vi.spyOn(window, "addEventListener"); + const removeWindowListener = vi.spyOn(window, "removeEventListener"); const getRunMock = vi.spyOn(api, "getRun").mockResolvedValue({ ...mockRun, status: "pending" }); const { unmount } = render(); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); const visibilityListener = addListener.mock.calls.find(([type]) => type === "visibilitychange")?.[1]; expect(visibilityListener).toBeDefined(); + const offlineListener = addWindowListener.mock.calls.find(([type]) => type === "offline")?.[1]; + const onlineListener = addWindowListener.mock.calls.find(([type]) => type === "online")?.[1]; + expect(offlineListener).toBeDefined(); + expect(onlineListener).toBeDefined(); const callsBeforeUnmount = getRunMock.mock.calls.length; unmount(); expect(removeListener).toHaveBeenCalledWith("visibilitychange", visibilityListener); + expect(removeWindowListener).toHaveBeenCalledWith("offline", offlineListener); + expect(removeWindowListener).toHaveBeenCalledWith("online", onlineListener); await act(async () => { vi.advanceTimersByTime(5000); await Promise.resolve(); }); expect(getRunMock).toHaveBeenCalledTimes(callsBeforeUnmount); }); @@ -802,6 +876,57 @@ describe("Dashboard Live Elapsed Timer (#41)", () => { expect(screen.getByTestId("run-duration").textContent).toBe("0s"); }); + + it("does not show an expiry hint when metadata is absent", async () => { + vi.setSystemTime(new Date("2026-10-16T12:00:00.000Z")); + vi.spyOn(api, "getRun").mockResolvedValue(mockRun); + + render(); + await screen.findByText("run-123"); + + expect(screen.queryByText(/Best-effort report retention deadline/)).toBeNull(); + expect(screen.getByRole("heading", { name: "Execution report" })).toBeDefined(); + }); + + it("shows a localized best-effort expiry hint for a future deadline", async () => { + vi.setSystemTime(new Date("2026-10-16T12:00:00.000Z")); + const expiresAt = "2026-10-16T13:00:00.000Z"; + vi.spyOn(api, "getRun").mockResolvedValue({ ...mockRun, expiresAt }); + + render(); + await screen.findByText("run-123"); + + const hint = screen.getByText(/Best-effort report retention deadline/); + expect(hint.textContent).toContain( + new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(expiresAt)), + ); + expect(hint.querySelector("time")?.getAttribute("dateTime")).toBe(expiresAt); + expect(hint.textContent).toContain("local time"); + expect(hint.textContent).toContain("Retention is not guaranteed"); + }); + + it("keeps the loaded report visible and shows the hint after its deadline", async () => { + vi.setSystemTime(new Date("2026-10-16T12:00:00.000Z")); + const expiresAt = "2026-10-16T11:00:00.000Z"; + vi.spyOn(api, "getRun").mockResolvedValue({ ...mockRun, expiresAt }); + + render(); + await screen.findByText("run-123"); + + expect(screen.getByText(/Best-effort report retention deadline/)).toBeDefined(); + expect(screen.getByRole("heading", { name: "Execution report" })).toBeDefined(); + }); + + it("ignores malformed expiry metadata without hiding the report", async () => { + vi.setSystemTime(new Date("2026-10-16T12:00:00.000Z")); + vi.spyOn(api, "getRun").mockResolvedValue({ ...mockRun, expiresAt: "not-a-date" }); + + render(); + await screen.findByText("run-123"); + + expect(screen.queryByText(/Best-effort report retention deadline/)).toBeNull(); + expect(screen.getByRole("heading", { name: "Execution report" })).toBeDefined(); + }); }); describe("Dashboard Skip Link (#44)", () => { @@ -909,4 +1034,3 @@ describe("Dashboard Report Download Feedback (#45)", () => { expect(screen.getByText("Backend service is temporarily unavailable. Please try again shortly.")).toBeDefined(); }); }); - diff --git a/esure-frontend/src/components/dashboard.tsx b/esure-frontend/src/components/dashboard.tsx index 33bb346..63688b0 100644 --- a/esure-frontend/src/components/dashboard.tsx +++ b/esure-frontend/src/components/dashboard.tsx @@ -106,6 +106,7 @@ export function Dashboard() { const [pollingError, setPollingError] = useState(null); const [consecutiveFailures, setConsecutiveFailures] = useState(0); const [isOffline, setIsOffline] = useState(false); + const [isBrowserOffline, setIsBrowserOffline] = useState(false); const [importError, setImportError] = useState(null); const [viewMode, setViewMode] = useState<"report" | "compare">("report"); const [reportFilter, setReportFilter] = useState("all"); @@ -133,6 +134,8 @@ export function Dashboard() { // Favorites state (#154) const [favorites, setFavorites] = useState>(() => getFavorites()); + const reconnectedRef = useRef(false); + const browserOfflineRef = useRef(false); // Idempotency key for run creation (#151) const [runIdempotencyKey, setRunIdempotencyKey] = useState(() => crypto.randomUUID()); @@ -142,6 +145,29 @@ export function Dashboard() { const searchParams = useSearchParams(); const runIdParam = searchParams?.get("runId"); + useEffect(() => { + function handleOffline() { + browserOfflineRef.current = true; + setIsBrowserOffline(true); + } + + function handleOnline() { + if (browserOfflineRef.current) reconnectedRef.current = true; + browserOfflineRef.current = false; + setIsBrowserOffline(false); + } + + browserOfflineRef.current = !navigator.onLine; + setIsBrowserOffline(browserOfflineRef.current); + window.addEventListener("offline", handleOffline); + window.addEventListener("online", handleOnline); + + return () => { + window.removeEventListener("offline", handleOffline); + window.removeEventListener("online", handleOnline); + }; + }, []); + // --- #31: read category from URL on first render --- const [filter, setFilter] = useState(() => parseCategory(searchParams?.get("category") ?? null), @@ -346,8 +372,9 @@ export function Dashboard() { }, [filteredScenarios, selectedId]); useEffect(() => { - if (!run || isOffline || terminalStatuses.has(run.status)) return; - if (consecutiveFailures >= 5) return; // Exhausted retries + if (!run || isOffline || isBrowserOffline || !navigator.onLine || terminalStatuses.has(run.status)) return; + const reconnectRead = reconnectedRef.current; + if (consecutiveFailures >= 5 && !reconnectRead) return; // Exhausted retries const activeRunId = run.id; let active = true; @@ -420,7 +447,12 @@ export function Dashboard() { const delay = consecutiveFailures === 0 ? 1000 : Math.pow(2, consecutiveFailures - 1) * 1000; document.addEventListener("visibilitychange", handleVisibilityChange); - schedule(delay); + if (reconnectRead) { + reconnectedRef.current = false; + void poll(); + } else { + schedule(delay); + } return () => { active = false; @@ -429,7 +461,7 @@ export function Dashboard() { inFlightController?.abort(); if (timerId !== null) window.clearTimeout(timerId); }; - }, [run, isOffline, consecutiveFailures]); + }, [run, isOffline, isBrowserOffline, consecutiveFailures]); const selected = useMemo(() => scenarios.find((scenario) => scenario.id === selectedId), [scenarios, selectedId]); @@ -748,6 +780,11 @@ export function Dashboard() {
+ {isBrowserOffline && ( +

+ You are offline. Run updates are paused and will resume when your connection returns. +

+ )}

02 / INSPECT THE RUN

{viewMode === "compare" ? "Report comparison" : "Execution report"}

@@ -882,6 +919,10 @@ function RunView({ run, reportFilter, scenarioName }: { run: RunReport; reportFi const createdLabel = formatLocaleDateTime(run.createdAt); const completedLabel = run.completedAt ? formatLocaleDateTime(run.completedAt) : null; + const expiryTimestamp = typeof run.expiresAt === "string" ? Date.parse(run.expiresAt) : NaN; + const expiryDate = Number.isFinite(expiryTimestamp) ? new Date(expiryTimestamp) : null; + const expiryIso = expiryDate?.toISOString(); + const expiryLabel = expiryIso ? formatLocaleDateTime(expiryIso) : null; const durationLabel = run.completedAt ? computeDuration(run.createdAt, run.completedAt) : isTerminal @@ -954,6 +995,12 @@ function RunView({ run, reportFilter, scenarioName }: { run: RunReport; reportFi {completedLabel ? <>COMPLETED{completedLabel} : null} {durationLabel ? <>DURATION{durationLabel} : null}
+ {expiryIso && expiryLabel && ( +

+ Best-effort report retention deadline: (local time). + Retention is not guaranteed; a report already loaded stays visible here. +

+ )} {!isTerminal &&
{titleCase(run.status)}Esure is executing this flow on Stellar Testnet.
} {reportFilter === "failed" && visibleSteps.length === 0 && visibleAssertions.length === 0 && (
diff --git a/esure-frontend/src/lib/api.test.ts b/esure-frontend/src/lib/api.test.ts index bc177ff..fe2d513 100644 --- a/esure-frontend/src/lib/api.test.ts +++ b/esure-frontend/src/lib/api.test.ts @@ -121,6 +121,15 @@ describe("frontend API response validation", () => { await expect(startRun("xlm-payment")).resolves.toEqual(run); }); + it("accepts optional expiry metadata without rejecting a report with malformed metadata", async () => { + const expiresAt = "2026-10-16T00:15:00.000Z"; + mockResponse(JSON.stringify({ ...run, expiresAt })); + await expect(getRun("run-123")).resolves.toMatchObject({ ...run, expiresAt }); + + mockResponse(JSON.stringify({ ...run, expiresAt: { invalid: true } })); + await expect(getRun("run-123")).resolves.toMatchObject({ ...run, expiresAt: { invalid: true } }); + }); + it("does not expose malformed upstream bodies for unsuccessful responses", async () => { mockResponse("private upstream diagnostic", 502); diff --git a/esure-frontend/src/lib/api.ts b/esure-frontend/src/lib/api.ts index 5edfedc..074c38c 100644 --- a/esure-frontend/src/lib/api.ts +++ b/esure-frontend/src/lib/api.ts @@ -37,6 +37,8 @@ export interface RunReport { status: RunStatus; createdAt: string; completedAt?: string; + /** Untrusted optional hint; malformed values must not invalidate a report. */ + expiresAt?: unknown; steps: StepResult[]; assertions: AssertionResult[]; summary: {