Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions esure-backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ export function buildApp(options: BuildAppOptions): AppInstance {

app.addHook("onRequest", async (request, reply) => {
const path = request.url.split("?")[0];
// Issue #85: /health is a cheap liveness probe that must stay reachable
// even when a client address has exhausted its application quota. Skip
// rate-limiting for this route entirely; all other routes, including the
// explicit readiness probe at /ready, continue to be covered.
if (path === "/health") return;
const isRunCreation = request.method === "POST" && (path === "/api/v1/runs" || path === "/api/v1/runs/definitions");
const limit = isRunCreation ? options.config.runRateLimitMax : options.config.rateLimitMax;
const result = limiter.consume(`${isRunCreation ? "run" : "global"}:${request.ip}`, limit);
Expand Down
8 changes: 8 additions & 0 deletions esure-backend/src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ export interface AssertionResult {
expected: AssertionValue;
actual: AssertionValue;
message: string;
/**
* Issue #84: bounded optional subject references copied from the validated
* assertion definition. Clients can use these to identify which account,
* asset, or step an assertion targets without re-parsing the scenario.
*/
account?: string;
asset?: string;
step?: string;
}

export type RunErrorCategory = "stellar" | "network" | "timeout" | "capacity" | "validation" | "internal";
Expand Down
16 changes: 15 additions & 1 deletion esure-backend/src/scenario-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,25 @@ export function contentHash(definition: ScenarioDefinition): string {
return `sha256:${createHash("sha256").update(canonicalJson(definition)).digest("hex")}`;
}

/**
* Produce a deterministic canonical JSON string with object keys sorted by
* Unicode code-unit order (i.e. the same ordering as the `<` / `>` operators
* on strings). Using a plain comparator instead of `localeCompare` makes the
* ordering independent of the host locale and ICU collation data, which means
* the same input always produces the same bytes regardless of the environment
* in which the process runs. This is the ordering contract for all content
* hashes produced by this module (issue #87).
*
* Compatibility note: the Unicode code-unit ordering used here matches the
* ordering that `localeCompare` produces under the `"en"` locale for all
* ASCII characters (the only characters that appear in scenario field names
* and identifiers), so existing stored hashes are unaffected.
*/
export function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
}
return JSON.stringify(value);
Expand Down
9 changes: 9 additions & 0 deletions esure-backend/src/scenario-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,17 @@ function validateAmounts(scenario: ScenarioDefinition): void {
if (issues.length) throw new ScenarioValidationError(issues);
}

/**
* Issue #86: step identifiers that the runner generates internally and that
* must not be used in user-authored scenario definitions.
*/
const RESERVED_STEP_IDS = new Set(["fund-accounts"]);

function validateReferences(scenario: ScenarioDefinition): void {
const issues: string[] = [];
for (const step of scenario.steps) {
if (RESERVED_STEP_IDS.has(step.id)) issues.push(`step id ${step.id} is reserved and cannot be used in a scenario definition`);
}
const accounts = uniqueIds(scenario.accounts, "accounts", issues);
const assets = uniqueIds(scenario.assets, "assets", issues);
const steps = uniqueIds(scenario.steps, "steps", issues);
Expand Down
14 changes: 13 additions & 1 deletion esure-backend/src/stellar-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export class StellarTestnetGateway implements LedgerGateway {

for (const definition of scenario.assertions) {
try {
const result = await this.evaluateAssertion(definition, accounts, assets, outcomes, initialBalances, steps, options);
const result = withSubjectRefs(await this.evaluateAssertion(definition, accounts, assets, outcomes, initialBalances, options), definition);
recordAssertion(assertions, result, options);
} catch (error) {
throw normalizeStellarError(error, `assert-${definition.type}`);
Expand Down Expand Up @@ -609,6 +609,18 @@ function comparison(type: AssertionResult["type"], expected: AssertionResult["ex
return { type, status: passed ? "passed" : "failed", expected, actual, message: passed ? success : `Expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.` };
}

/**
* Issue #84: attach bounded optional subject references from the assertion
* definition onto the result so clients can identify what was tested.
*/
function withSubjectRefs(result: AssertionResult, definition: ScenarioAssertion): AssertionResult {
const refs: { account?: string; asset?: string; step?: string } = {};
if ("account" in definition) refs.account = definition.account;
if ("asset" in definition) refs.asset = definition.asset;
if ("step" in definition) refs.step = definition.step;
return Object.keys(refs).length ? { ...result, ...refs } : result;
}

export function balanceDeltaAssertion(before: string, after: string, expectedDelta: string, success: string): AssertionResult {
const actual = stroopsToDecimal(decimalToStroops(after) - decimalToStroops(before));
return comparison("balanceChangedBy", canonicalDecimal(expectedDelta), actual, success);
Expand Down