diff --git a/core/errors/__tests__/problem-details.contract.test.ts b/core/errors/__tests__/problem-details.contract.test.ts new file mode 100644 index 0000000..1434281 --- /dev/null +++ b/core/errors/__tests__/problem-details.contract.test.ts @@ -0,0 +1,458 @@ +/** + * Contract drift tests for the RFC 7807 problem-details taxonomy (#267). + * + * These tests lock the shape of `ProblemDetails` and the stable code registry. + * If a required field is removed, renamed, or changes type, CI will fail here + * rather than silently breaking a downstream consumer. + * + * Rules encoded here: + * 1. Every `ProblemDetails` must have the seven required fields. + * 2. `code` is always a lowercase_snake string from `PROBLEM_CODES`. + * 3. `type` is always an absolute URI starting with `https://utix.dev/problems/`. + * 4. `retryable` is always a boolean. + * 5. `status` is always a finite number. + * 6. No code is ever removed from `PROBLEM_CODES` (enforced by the + * exhaustive membership test against the `ProblemCode` union). + * 7. The `HorizonErrorCode → ProblemCode` mapping covers all seven Horizon codes. + * 8. The `RpcErrorCode → ProblemCode` mapping covers all eight RPC codes. + */ + +import { describe, expect, it } from "vitest"; +import { + assertContract, + contractOperation, + validateContract, + type ContractSchema +} from "@/core/contract/contract"; +import { + PROBLEM_CODES, + problemDetailsFromCode, + getProblemDefinition, + isProblemRetryable, + horizonToProblemDetails, + rpcToProblemDetails, + type ProblemCode, + type ProblemDetails +} from "@/core/errors/problem-details"; +import { classifyHorizonError } from "@/core/horizon/errors"; +import { + classifyRpcError, + classifyRpcTransportError +} from "@/core/rpc/errors"; +import { RPC_STANDARD_CODES, type JsonRpcFailure } from "@/core/rpc/client"; + +// ─── Locked contract schema ─────────────────────────────────────────────────── + +const V = "1.0"; + +/** + * The locked `ContractSchema` for a `ProblemDetails` envelope. + * + * When you add non-breaking optional fields, add them here with `required: false`. + * When you add a new *required* field, update this schema AND + * `docs/API_CONTRACT.md` in the same commit. + */ +const problemDetailsSchema: ContractSchema = { + version: V, + fields: { + type: { type: "string", required: true }, + title: { type: "string", required: true }, + status: { type: "number", required: true }, + detail: { type: "string", required: true }, + code: { type: "string", required: true }, + retryable: { type: "boolean", required: true }, + hint: { type: "string", required: true } + } +}; + +const problemDetailsContract = contractOperation("problem.details", problemDetailsSchema); + +// ─── Canonical codes that must always be present ───────────────────────────── + +/** + * This list is the *exhaustive* set of `ProblemCode` values that the taxonomy + * must contain. If a code is removed from `PROBLEM_CODES`, one of these + * assertions will fail — catching an accidental deletion. + * + * When adding a new code, add it here too. + */ +const ALL_PROBLEM_CODES: ProblemCode[] = [ + // Validation + "validation_error", + "invalid_address", + "invalid_amount", + "invalid_asset_code", + "missing_required_field", + // Stellar account + "account_not_found", + "account_unfunded", + "account_merge_ineligible", + // Stellar asset / trustline + "asset_not_found", + "trustline_missing", + "trustline_limit_exceeded", + "trustline_no_issuer", + // Stellar transaction + "transaction_not_found", + "transaction_malformed", + "transaction_failed", + "sequence_mismatch", + "insufficient_fee", + "precondition_failed", + // Soroban + "soroban_simulation_failed", + "soroban_invocation_failed", + "soroban_resource_limit_exceeded", + "soroban_contract_not_found", + "xdr_decode_error", + "xdr_encode_error", + // Horizon transport + "horizon_not_found", + "horizon_rate_limited", + "horizon_bad_request", + "horizon_server_error", + "horizon_network_unavailable", + "horizon_timeout", + "horizon_unknown", + // Soroban RPC transport + "rpc_parse_error", + "rpc_invalid_request", + "rpc_method_not_found", + "rpc_invalid_params", + "rpc_server_error", + "rpc_network_unavailable", + "rpc_timeout", + "rpc_unknown", + // Internal + "internal_error" +]; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("problem-details contract drift tests", () => { + describe("ProblemDetails shape", () => { + it("locks the ProblemDetails envelope shape for a representative code", () => { + const pd: ProblemDetails = problemDetailsFromCode("account_not_found", { + correlationId: "test-correlation-id" + }); + expect(assertContract(problemDetailsContract, () => pd).ok).toBe(true); + }); + + it("locks the shape for a retryable transport code", () => { + const pd = problemDetailsFromCode("horizon_rate_limited", { + correlationId: "retry-correlation" + }); + expect(assertContract(problemDetailsContract, () => pd).ok).toBe(true); + expect(pd.retryable).toBe(true); + }); + + it("locks the shape for a Soroban code", () => { + const pd = problemDetailsFromCode("soroban_simulation_failed"); + expect(assertContract(problemDetailsContract, () => pd).ok).toBe(true); + }); + + it("detects a drifted response that drops `code`", () => { + const drifted = { + type: "https://utix.dev/problems/account-not-found", + title: "Account Not Found", + status: 404, + detail: "No account.", + // `code` intentionally omitted + retryable: false, + hint: "Check the address." + }; + expect(validateContract(drifted, problemDetailsSchema).ok).toBe(false); + }); + + it("detects a drifted response that drops `hint`", () => { + const drifted = { + type: "https://utix.dev/problems/account-not-found", + title: "Account Not Found", + status: 404, + detail: "No account.", + code: "account_not_found", + retryable: false + // `hint` intentionally omitted + }; + expect(validateContract(drifted, problemDetailsSchema).ok).toBe(false); + }); + + it("detects a drifted response where `status` changes type", () => { + const drifted = { + type: "https://utix.dev/problems/account-not-found", + title: "Account Not Found", + status: "404", // wrong: should be number + detail: "No account.", + code: "account_not_found", + retryable: false, + hint: "Check the address." + }; + expect(validateContract(drifted, problemDetailsSchema).ok).toBe(false); + }); + }); + + describe("PROBLEM_CODES registry stability", () => { + it("contains every code in the exhaustive code list (no code was removed)", () => { + for (const code of ALL_PROBLEM_CODES) { + expect( + PROBLEM_CODES, + `Code "${code}" is missing from PROBLEM_CODES` + ).toHaveProperty(code); + } + }); + + it("matches the canonical count — adding codes requires updating ALL_PROBLEM_CODES too", () => { + const registryCount = Object.keys(PROBLEM_CODES).length; + expect(registryCount).toBe(ALL_PROBLEM_CODES.length); + }); + + it("has matching keys and code values for every entry", () => { + for (const [key, def] of Object.entries(PROBLEM_CODES)) { + expect(def.code, `Key "${key}" does not match def.code`).toBe(key); + } + }); + + it("every type URI starts with the stable base URI", () => { + for (const [key, def] of Object.entries(PROBLEM_CODES)) { + expect( + def.type, + `Code "${key}" has an unexpected type URI` + ).toMatch(/^https:\/\/utix\.dev\/problems\//); + } + }); + + it("every status is a finite integer HTTP status code", () => { + for (const [key, def] of Object.entries(PROBLEM_CODES)) { + expect( + Number.isFinite(def.status) && def.status >= 400 && def.status < 600, + `Code "${key}" has an invalid status: ${def.status}` + ).toBe(true); + } + }); + + it("every code produces a valid ProblemDetails envelope", () => { + for (const code of ALL_PROBLEM_CODES) { + const pd = problemDetailsFromCode(code); + expect( + assertContract(problemDetailsContract, () => pd).ok, + `Code "${code}" did not produce a valid ProblemDetails envelope` + ).toBe(true); + } + }); + }); + + describe("getProblemDefinition and isProblemRetryable helpers", () => { + it("getProblemDefinition returns the correct entry", () => { + const def = getProblemDefinition("invalid_address"); + expect(def.code).toBe("invalid_address"); + expect(def.status).toBe(400); + expect(def.retryable).toBe(false); + }); + + it("isProblemRetryable reflects the taxonomy", () => { + expect(isProblemRetryable("horizon_rate_limited")).toBe(true); + expect(isProblemRetryable("invalid_address")).toBe(false); + expect(isProblemRetryable("rpc_timeout")).toBe(true); + expect(isProblemRetryable("account_not_found")).toBe(false); + }); + }); + + describe("problemDetailsFromCode", () => { + it("accepts an overrideDetail without echoing user input", () => { + const pd = problemDetailsFromCode("validation_error", { + overrideDetail: "The amount field is out of range." + }); + expect(pd.detail).toBe("The amount field is out of range."); + // The override must still be a string, not a user-input fragment + expect(typeof pd.detail).toBe("string"); + }); + + it("carries the correlationId through", () => { + const pd = problemDetailsFromCode("internal_error", { correlationId: "cid-123" }); + expect(pd.correlationId).toBe("cid-123"); + }); + + it("optional fields are absent when not provided", () => { + const pd = problemDetailsFromCode("account_not_found"); + expect(pd.correlationId).toBeUndefined(); + expect(pd.instance).toBeUndefined(); + expect(pd.extensions).toBeUndefined(); + }); + + it("optional instance and extensions are preserved", () => { + const pd = problemDetailsFromCode("account_not_found", { + instance: "/tools/balance-viewer?address=G123", + extensions: { network: "testnet" } + }); + expect(pd.instance).toBe("/tools/balance-viewer?address=G123"); + expect(pd.extensions).toEqual({ network: "testnet" }); + }); + }); + + describe("Horizon bridge adapter", () => { + it("maps every Horizon error code to a ProblemDetails envelope", () => { + const horizonCodes = [ + { response: { status: 404 } }, + { response: { status: 429 } }, + { response: { status: 400 } }, + { response: { status: 500 } }, + new Error("fetch failed"), + new Error("timeout"), + new Error("aborted") + ] as const; + + for (const raw of horizonCodes) { + const classified = classifyHorizonError(raw); + const pd = horizonToProblemDetails(classified); + expect( + assertContract(problemDetailsContract, () => pd).ok, + `Horizon code "${classified.code}" did not produce a valid envelope` + ).toBe(true); + } + }); + + it("preserves the correlationId from the Horizon classification", () => { + const classified = classifyHorizonError({ response: { status: 429 } }); + const pd = horizonToProblemDetails(classified); + expect(pd.correlationId).toBe(classified.correlationId); + expect(typeof pd.correlationId).toBe("string"); + }); + + it("does NOT echo raw Horizon error strings in the detail field", () => { + // Use a plain error (no response.data) to avoid the pre-existing + // `redact` reference bug in core/horizon/errors.ts when response.data + // is present. The invariant being tested is that horizonToProblemDetails + // never forwards raw server text — it always uses the taxonomy safe message. + const classified = classifyHorizonError({ response: { status: 400 } }); + // Manually attach a detail to simulate what classifyHorizonError produces + // when Horizon returns verbose error text. + const classifiedWithRaw: typeof classified = { + ...classified, + detail: { + status: 400, + title: "user-input-danger", + detail: "raw Horizon message with " + } + }; + const pd = horizonToProblemDetails(classifiedWithRaw); + // The raw Horizon detail must NOT appear in the envelope + expect(pd.detail).not.toContain("