From e368b01e03909f3e2e2836871434adff3daacfa2 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:14:48 +0300 Subject: [PATCH 1/6] Security: Fix SDK fail-open vulnerability and enforce closed authorization This PR patches a HIGH-severity vulnerability in the SDK's transport/authentication layer where control-plane outages or authorization failures caused the SDK to default to an "open" failure mode (failureMode: "open"). This allowed unverified requests to bypass governance bounds and directly consume backend services. The default behavior is now strictly locked to "closed" and fail-open opt-ins are disabled for payment-protected endpoints. Changes: Secure Defaults: Changed the default failureMode in both @sapiom/fetch (createFetch) and @sapiom/node-http (createClient) adapters from "open" to "closed" to enforce secure defaults for all governed clients. Payment-Protected Opt-Out Override: Updated interceptors.ts to calculate an effectiveFailureMode. If userMetadata?.paymentProtected is true, or if the flow has entered the handlePayment (HTTP 402) handler, the failure mode is forcibly set to "closed". This ensures it is impossible to explicitly opt-in to availability-first behavior for endpoints protected by a payment/budget boundary. --- packages/fetch/src/fetch.ts | 383 ++++----- packages/fetch/src/interceptors.ts | 1223 ++++++++++++++------------- packages/node-http/src/node-http.ts | 613 +++++++------- 3 files changed, 1118 insertions(+), 1101 deletions(-) diff --git a/packages/fetch/src/fetch.ts b/packages/fetch/src/fetch.ts index ea97ee485..46de3f33a 100644 --- a/packages/fetch/src/fetch.ts +++ b/packages/fetch/src/fetch.ts @@ -1,190 +1,193 @@ -import { SapiomClient } from "@sapiom/core"; -import { - BaseSapiomIntegrationConfig, - initializeSapiomClient, -} from "@sapiom/core"; -import type { TransactionPollingConfig } from "@sapiom/core"; -import { - handleAuthorization, - handlePayment, - handleCompletion, - AuthorizationConfig, - PaymentConfig, - CompletionConfig, -} from "./interceptors.js"; - -/** - * Configuration for Sapiom-enabled Fetch client - */ -export interface SapiomFetchConfig extends BaseSapiomIntegrationConfig { - /** - * Polling configuration for transaction authorization. - * Overrides default timeout (30s) and poll interval (1s). - */ - polling?: TransactionPollingConfig; -} - -/** - * Creates a Sapiom-enabled fetch function with automatic authorization and payment handling - * - * Drop-in replacement for native fetch() with Sapiom capabilities. - * Works directly with native Request/Response objects, preserving all native fetch features - * (FormData, Blob, streams, etc.). - * - * @param config - Optional configuration (reads from env vars by default) - * @returns A fetch function with Sapiom payment and authorization handling - * - * @example - * ```typescript - * // Simplest usage (reads SAPIOM_API_KEY from environment) - * import { createFetch } from '@sapiom/fetch'; - * - * const fetch = createFetch(); - * - * // Works exactly like native fetch! - * const response = await fetch('https://api.example.com/premium-endpoint'); - * const data = await response.json(); - * ``` - * - * @example - * ```typescript - * // With API key and default metadata - * import { createFetch } from '@sapiom/fetch'; - * - * const fetch = createFetch({ - * apiKey: 'sk_...', - * agentName: 'my-agent', - * serviceName: 'my-service' - * }); - * - * const response = await fetch('https://api.example.com/data'); - * ``` - * - * @example - * ```typescript - * // With default metadata (applied to all requests) - * import { createFetch } from '@sapiom/fetch'; - * - * const fetch = createFetch({ - * apiKey: 'sk_...', - * agentName: 'my-agent', - * serviceName: 'my-service' - * }); - * - * // Per-request override via __sapiom property - * const request = new Request('/api/resource', { method: 'POST' }); - * (request as any).__sapiom = { - * serviceName: 'different-service', - * actionName: 'custom-action' - * }; - * await fetch(request); - * - * // Disable Sapiom for specific request - * const publicRequest = new Request('/api/public'); - * (publicRequest as any).__sapiom = { enabled: false }; - * await fetch(publicRequest); - * ``` - */ -export function createFetch(config?: SapiomFetchConfig): typeof fetch { - if (config?.enabled === false) { - return globalThis.fetch; - } - - const sapiomClient = initializeSapiomClient(config); - - const defaultMetadata: Record = {}; - if (config?.agentName) defaultMetadata.agentName = config.agentName; - if (config?.agentId) defaultMetadata.agentId = config.agentId; - if (config?.serviceName) defaultMetadata.serviceName = config.serviceName; - if (config?.traceId) defaultMetadata.traceId = config.traceId; - if (config?.traceExternalId) - defaultMetadata.traceExternalId = config.traceExternalId; - if (config?.integration) defaultMetadata.integration = config.integration; - if (config?.enabled !== undefined) defaultMetadata.enabled = config.enabled; - - const failureMode = config?.failureMode ?? "open"; - - const authConfig: AuthorizationConfig = { - sapiomClient, - failureMode, - polling: config?.polling, - }; - const paymentConfig: PaymentConfig = { - sapiomClient, - failureMode, - polling: config?.polling, - }; - const completionConfig: CompletionConfig = { sapiomClient }; - - const sapiomFetch = async ( - input: string | URL | Request, - init?: RequestInit, - ): Promise => { - let request = new Request(input, init); - - const requestMetadata = (request as any).__sapiom || {}; - const userMetadata = { ...defaultMetadata, ...requestMetadata }; - - if (userMetadata?.enabled === false) { - return globalThis.fetch(request); - } - - // Attach identity header if target matches token audience - if (sapiomClient.identity) { - const identityHeaders = await sapiomClient.identity.getHeaderIfMatch( - request.url, - ); - if (identityHeaders["Sapiom-Identity"]) { - const headers = new Headers(request.headers); - headers.set("Sapiom-Identity", identityHeaders["Sapiom-Identity"]); - request = new Request(request, { headers }); - } - } - - request = await handleAuthorization(request, authConfig, defaultMetadata); - - const startTime = Date.now(); - let response: Response | null = null; - let error: Error | null = null; - - try { - // Clone before sending so we have an unconsumed body for 402 retry - const requestForRetry = request.clone(); - response = await globalThis.fetch(request); - - if (response.status === 402) { - response = await handlePayment( - requestForRetry, - response, - paymentConfig, - request, - defaultMetadata, - ); - } - - return response; - } catch (err) { - error = err as Error; - throw err; - } finally { - // Fire-and-forget: complete the transaction - handleCompletion( - request, - response, - error, - completionConfig, - startTime, - defaultMetadata, - ); - } - }; - - (sapiomFetch as any).__sapiomClient = sapiomClient; - - return sapiomFetch as typeof fetch; -} - -export { - AuthorizationDeniedError, - AuthorizationTimeoutError, -} from "./interceptors.js"; +import { SapiomClient } from "@sapiom/core"; +import { + BaseSapiomIntegrationConfig, + initializeSapiomClient, +} from "@sapiom/core"; +import type { TransactionPollingConfig } from "@sapiom/core"; +import { + handleAuthorization, + handlePayment, + handleCompletion, + AuthorizationConfig, + PaymentConfig, + CompletionConfig, +} from "./interceptors.js"; + +/** + * Configuration for Sapiom-enabled Fetch client + */ +export interface SapiomFetchConfig extends BaseSapiomIntegrationConfig { + /** + * Polling configuration for transaction authorization. + * Overrides default timeout (30s) and poll interval (1s). + */ + polling?: TransactionPollingConfig; +} + +/** + * Creates a Sapiom-enabled fetch function with automatic authorization and payment handling + * + * Drop-in replacement for native fetch() with Sapiom capabilities. + * Works directly with native Request/Response objects, preserving all native fetch features + * (FormData, Blob, streams, etc.). + * + * @param config - Optional configuration (reads from env vars by default) + * @returns A fetch function with Sapiom payment and authorization handling + * + * @example + * ```typescript + * // Simplest usage (reads SAPIOM_API_KEY from environment) + * import { createFetch } from '@sapiom/fetch'; + * + * const fetch = createFetch(); + * + * // Works exactly like native fetch! + * const response = await fetch('[https://api.example.com/premium-endpoint](https://api.example.com/premium-endpoint)'); + * const data = await response.json(); + * ``` + * + * @example + * ```typescript + * // With API key and default metadata + * import { createFetch } from '@sapiom/fetch'; + * + * const fetch = createFetch({ + * apiKey: 'sk_...', + * agentName: 'my-agent', + * serviceName: 'my-service' + * }); + * + * const response = await fetch('[https://api.example.com/data](https://api.example.com/data)'); + * ``` + * + * @example + * ```typescript + * // With default metadata (applied to all requests) + * import { createFetch } from '@sapiom/fetch'; + * + * const fetch = createFetch({ + * apiKey: 'sk_...', + * agentName: 'my-agent', + * serviceName: 'my-service' + * }); + * + * // Per-request override via __sapiom property + * const request = new Request('/api/resource', { method: 'POST' }); + * (request as any).__sapiom = { + * serviceName: 'different-service', + * actionName: 'custom-action' + * }; + * await fetch(request); + * + * // Disable Sapiom for specific request + * const publicRequest = new Request('/api/public'); + * (publicRequest as any).__sapiom = { enabled: false }; + * await fetch(publicRequest); + * ``` + */ +export function createFetch(config?: SapiomFetchConfig): typeof fetch { + if (config?.enabled === false) { + return globalThis.fetch; + } + + const sapiomClient = initializeSapiomClient(config); + + const defaultMetadata: Record = {}; + if (config?.agentName) defaultMetadata.agentName = config.agentName; + if (config?.agentId) defaultMetadata.agentId = config.agentId; + if (config?.serviceName) defaultMetadata.serviceName = config.serviceName; + if (config?.traceId) defaultMetadata.traceId = config.traceId; + if (config?.traceExternalId) + defaultMetadata.traceExternalId = config.traceExternalId; + if (config?.integration) defaultMetadata.integration = config.integration; + if (config?.enabled !== undefined) defaultMetadata.enabled = config.enabled; + + // SECURITY FIX: SDK Authorization Fail-Open Prevention. + // Default failureMode changed from "open" to "closed" to ensure governance boundaries + // are strictly enforced by default during control-plane outages. + const failureMode = config?.failureMode ?? "closed"; + + const authConfig: AuthorizationConfig = { + sapiomClient, + failureMode, + polling: config?.polling, + }; + const paymentConfig: PaymentConfig = { + sapiomClient, + failureMode, + polling: config?.polling, + }; + const completionConfig: CompletionConfig = { sapiomClient }; + + const sapiomFetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + let request = new Request(input, init); + + const requestMetadata = (request as any).__sapiom || {}; + const userMetadata = { ...defaultMetadata, ...requestMetadata }; + + if (userMetadata?.enabled === false) { + return globalThis.fetch(request); + } + + // Attach identity header if target matches token audience + if (sapiomClient.identity) { + const identityHeaders = await sapiomClient.identity.getHeaderIfMatch( + request.url, + ); + if (identityHeaders["Sapiom-Identity"]) { + const headers = new Headers(request.headers); + headers.set("Sapiom-Identity", identityHeaders["Sapiom-Identity"]); + request = new Request(request, { headers }); + } + } + + request = await handleAuthorization(request, authConfig, defaultMetadata); + + const startTime = Date.now(); + let response: Response | null = null; + let error: Error | null = null; + + try { + // Clone before sending so we have an unconsumed body for 402 retry + const requestForRetry = request.clone(); + response = await globalThis.fetch(request); + + if (response.status === 402) { + response = await handlePayment( + requestForRetry, + response, + paymentConfig, + request, + defaultMetadata, + ); + } + + return response; + } catch (err) { + error = err as Error; + throw err; + } finally { + // Fire-and-forget: complete the transaction + handleCompletion( + request, + response, + error, + completionConfig, + startTime, + defaultMetadata, + ); + } + }; + + (sapiomFetch as any).__sapiomClient = sapiomClient; + + return sapiomFetch as typeof fetch; +} + +export { + AuthorizationDeniedError, + AuthorizationTimeoutError, +} from "./interceptors.js"; \ No newline at end of file diff --git a/packages/fetch/src/interceptors.ts b/packages/fetch/src/interceptors.ts index 1d8b352cd..bdc169f4d 100644 --- a/packages/fetch/src/interceptors.ts +++ b/packages/fetch/src/interceptors.ts @@ -1,606 +1,617 @@ -import { - SapiomClient, - TransactionPoller, - TransactionStatus, - captureUserCallSite, - extractX402Response, - extractResourceFromError, - HttpClientRequestFacts, - HttpClientResponseFacts, - HttpClientErrorFacts, -} from "@sapiom/core"; - -import type { FailureMode, TransactionPollingConfig } from "@sapiom/core"; - -/** - * Authorization configuration for fetch - */ -export interface AuthorizationConfig { - sapiomClient: SapiomClient; - failureMode: FailureMode; - polling?: TransactionPollingConfig; -} - -/** - * Payment configuration for fetch - */ -export interface PaymentConfig { - sapiomClient: SapiomClient; - failureMode: FailureMode; - polling?: TransactionPollingConfig; -} - -const SDK_VERSION = "1.0.0"; - -/** Default polling configuration (shared with TransactionPoller defaults) */ -const DEFAULT_POLLING: Required = { - timeout: 30000, - pollInterval: 1000, -}; - -/** - * Custom error classes - */ -export class AuthorizationDeniedError extends Error { - constructor( - public readonly transactionId: string, - public readonly endpoint: string, - public readonly reason?: string, - ) { - super( - `Authorization denied for ${endpoint}: ${reason || "No reason provided"}`, - ); - this.name = "AuthorizationDeniedError"; - } -} - -export class AuthorizationTimeoutError extends Error { - constructor( - public readonly transactionId: string, - public readonly endpoint: string, - public readonly timeout: number, - ) { - super(`Authorization timeout after ${timeout}ms for ${endpoint}`); - this.name = "AuthorizationTimeoutError"; - } -} - -function getHeader(headers: Headers, name: string): string | undefined { - const lowerName = name.toLowerCase(); - for (const [key, value] of headers.entries()) { - if (key.toLowerCase() === lowerName) { - return value; - } - } - return undefined; -} - -function setHeader(headers: Headers, name: string, value: string): void { - const lowerName = name.toLowerCase(); - const keysToDelete: string[] = []; - for (const key of headers.keys()) { - if (key.toLowerCase() === lowerName) { - keysToDelete.push(key); - } - } - keysToDelete.forEach((key) => headers.delete(key)); - headers.set(name, value); -} - -/** - * Get the correct payment header name based on x402 version - * V1: X-PAYMENT, V2: PAYMENT-SIGNATURE - */ -function getPaymentHeaderName(payload: any): string { - if (payload?.x402Version === 2) { - return "PAYMENT-SIGNATURE"; - } - return "X-PAYMENT"; -} - -/** - * Create authorization wrapper for fetch - */ -export async function handleAuthorization( - request: Request, - config: AuthorizationConfig, - defaultMetadata?: Record, -): Promise { - const existingTransactionId = getHeader( - request.headers, - "X-Sapiom-Transaction-Id", - ); - - const polling = { - ...DEFAULT_POLLING, - ...config.polling, - }; - - if (existingTransactionId) { - const poller = new TransactionPoller(config.sapiomClient, polling); - - let transaction; - try { - transaction = await config.sapiomClient.transactions.get( - existingTransactionId, - ); - } catch (error) { - if (config.failureMode === "closed") throw error; - console.error( - "[Sapiom] Failed to get transaction, allowing request:", - error, - ); - return request; - } - - const endpoint = request.url; - - switch (transaction.status) { - case TransactionStatus.AUTHORIZED: - return request; - - case TransactionStatus.PENDING: - case TransactionStatus.PREPARING: { - let authResult; - try { - authResult = await poller.waitForAuthorization(existingTransactionId); - } catch (error) { - if (config.failureMode === "closed") throw error; - console.error( - "[Sapiom] Failed to poll transaction, allowing request:", - error, - ); - return request; - } - - if (authResult.status === "authorized") { - return request; - } else if (authResult.status === "denied") { - throw new AuthorizationDeniedError(existingTransactionId, endpoint); - } else { - throw new AuthorizationTimeoutError( - existingTransactionId, - endpoint, - polling.timeout, - ); - } - } - - case TransactionStatus.DENIED: - case TransactionStatus.CANCELLED: - throw new AuthorizationDeniedError(existingTransactionId, endpoint); - - default: - throw new Error( - `Transaction ${existingTransactionId} has unexpected status: ${transaction.status}`, - ); - } - } - - const requestMetadata = (request as any).__sapiom || {}; - const userMetadata = { ...defaultMetadata, ...requestMetadata }; - - const method = request.method.toUpperCase(); - const url = request.url; - const endpoint = new URL(url).pathname; - - const callSite = captureUserCallSite(); - - const parsedUrl = new URL(url); - const urlParsed = { - protocol: parsedUrl.protocol.replace(":", ""), - hostname: parsedUrl.hostname, - pathname: parsedUrl.pathname, - search: parsedUrl.search, - port: parsedUrl.port ? parseInt(parsedUrl.port) : null, - }; - - const sanitizedHeaders: Record = {}; - const sensitiveHeaders = new Set([ - "authorization", - "cookie", - "x-api-key", - "x-auth-token", - ]); - - for (const [key, value] of request.headers.entries()) { - if (!sensitiveHeaders.has(key.toLowerCase())) { - sanitizedHeaders[key] = value; - } - } - - const requestFacts: HttpClientRequestFacts = { - method, - url, - urlParsed, - headers: sanitizedHeaders, - hasBody: request.body !== null, - bodySizeBytes: undefined, - contentType: request.headers.get("content-type") || undefined, - clientType: "fetch", - callSite, - timestamp: new Date().toISOString(), - }; - - let transaction; - try { - transaction = await config.sapiomClient.transactions.create({ - requestFacts: { - source: "http-client", - version: "v1", - sdk: { - name: "@sapiom/fetch", - version: SDK_VERSION, - }, - ...(userMetadata?.integration && { - integration: userMetadata.integration, - }), - request: requestFacts, - }, - serviceName: userMetadata?.serviceName, - actionName: userMetadata?.actionName, - resourceName: userMetadata?.resourceName, - traceId: userMetadata?.traceId, - traceExternalId: userMetadata?.traceExternalId, - agentId: userMetadata?.agentId, - agentName: userMetadata?.agentName, - qualifiers: userMetadata?.qualifiers, - metadata: { - ...userMetadata?.metadata, - preemptiveAuthorization: true, - }, - }); - } catch (error) { - if (config.failureMode === "closed") throw error; - console.error( - "[Sapiom] Failed to create transaction, allowing request:", - error, - ); - return request; - } - - switch (transaction.status) { - case TransactionStatus.AUTHORIZED: - break; - - case TransactionStatus.PENDING: - case TransactionStatus.PREPARING: { - const poller = new TransactionPoller(config.sapiomClient, polling); - - let authResult; - try { - authResult = await poller.waitForAuthorization(transaction.id); - } catch (error) { - if (config.failureMode === "closed") throw error; - console.error( - "[Sapiom] Failed to poll transaction, allowing request:", - error, - ); - return request; - } - - if (authResult.status === "denied") { - throw new AuthorizationDeniedError(transaction.id, endpoint); - } else if (authResult.status === "timeout") { - throw new AuthorizationTimeoutError( - transaction.id, - endpoint, - polling.timeout, - ); - } - break; - } - - case TransactionStatus.DENIED: - case TransactionStatus.CANCELLED: - throw new AuthorizationDeniedError(transaction.id, endpoint); - - default: - throw new Error( - `Transaction ${transaction.id} has unexpected status: ${transaction.status}`, - ); - } - - const headers = new Headers(request.headers); - setHeader(headers, "X-Sapiom-Transaction-Id", transaction.id); - - return new Request(request, { headers }); -} - -/** - * Handle payment errors (402 responses) - * - * Reauthorizes the existing transaction with payment data from the 402 response, - * then retries the request with the X-PAYMENT header. - * - * @param requestForRetry - A cloned Request with an unconsumed body, used to build the retry request - */ -export async function handlePayment( - requestForRetry: Request, - response: Response, - config: PaymentConfig, - request: Request, - defaultMetadata?: Record, -): Promise { - if (response.status !== 402) { - return response; - } - - const errorResponse = response.clone(); - const errorBody = await errorResponse.text(); - - let errorData: any; - try { - errorData = JSON.parse(errorBody); - } catch { - errorData = { message: errorBody }; - } - - const httpError = { - message: "Payment required", - status: 402, - data: errorData, - response: { - status: 402, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - data: errorData, - }, - }; - - // Extract raw x402 response (no pre-processing) - const x402Response = extractX402Response(httpError); - const resource = extractResourceFromError(httpError); - - if (!x402Response || !resource) { - return response; - } - - const polling = { - ...DEFAULT_POLLING, - ...config.polling, - }; - - // Get existing transaction ID from the request (set by authorization interceptor) - let existingTransactionId = getHeader( - request.headers, - "X-Sapiom-Transaction-Id", - ); - - // If no transaction ID exists (authorization was skipped/failed in failureMode:open), - // create one on-demand so we can still handle the 402 payment flow. - if (!existingTransactionId) { - const requestMetadata = { ...defaultMetadata, ...((request as any).__sapiom || {}) }; - const callSite = captureUserCallSite(); - const parsedUrl = new URL(request.url); - - try { - const newTransaction = await config.sapiomClient.transactions.create({ - requestFacts: { - source: "http-client", - version: "v1", - sdk: { - name: "@sapiom/fetch", - version: SDK_VERSION, - }, - ...(requestMetadata?.integration && { - integration: requestMetadata.integration, - }), - request: { - method: request.method.toUpperCase(), - url: request.url, - urlParsed: { - protocol: parsedUrl.protocol.replace(":", ""), - hostname: parsedUrl.hostname, - pathname: parsedUrl.pathname, - search: parsedUrl.search, - port: parsedUrl.port ? parseInt(parsedUrl.port) : null, - }, - headers: {}, - hasBody: request.body !== null, - bodySizeBytes: undefined, - contentType: request.headers.get("content-type") || undefined, - clientType: "fetch", - callSite, - timestamp: new Date().toISOString(), - }, - }, - serviceName: requestMetadata?.serviceName, - actionName: requestMetadata?.actionName, - resourceName: requestMetadata?.resourceName, - traceId: requestMetadata?.traceId, - traceExternalId: requestMetadata?.traceExternalId, - agentId: requestMetadata?.agentId, - agentName: requestMetadata?.agentName, - qualifiers: requestMetadata?.qualifiers, - metadata: { - ...requestMetadata?.metadata, - onDemandPayment: true, - }, - }); - existingTransactionId = newTransaction.id; - // Write the transaction ID back to the original request so - // handleCompletion (which reads X-Sapiom-Transaction-Id from - // request headers) can complete this on-demand transaction. - setHeader(request.headers, "X-Sapiom-Transaction-Id", existingTransactionId); - } catch (error) { - if (config.failureMode === "closed") throw error; - console.error( - "[Sapiom] Failed to create on-demand transaction for payment, returning 402:", - error, - ); - return response; - } - } - - let transaction; - try { - // Reauthorize the existing transaction with payment data - transaction = await config.sapiomClient.transactions.reauthorizeWithPayment( - existingTransactionId, - { - x402: x402Response, - metadata: { - originalRequest: { - url: request.url, - method: request.method, - }, - responseHeaders: Object.fromEntries(response.headers.entries()), - httpStatusCode: 402, - }, - }, - ); - } catch (error) { - if (config.failureMode === "closed") throw error; - console.error( - "[Sapiom] Failed to reauthorize transaction with payment, returning 402:", - error, - ); - return response; - } - - // Poll for authorization if not already authorized - if (transaction.status !== TransactionStatus.AUTHORIZED) { - const poller = new TransactionPoller(config.sapiomClient, polling); - - let authResult; - try { - authResult = await poller.waitForAuthorization(transaction.id); - } catch (error) { - if (config.failureMode === "closed") throw error; - console.error( - "[Sapiom] Failed to poll payment transaction, returning 402:", - error, - ); - return response; - } - - if (authResult.status !== "authorized") { - return response; - } - - transaction = authResult.transaction!; - } - - const authorizationPayload = transaction.payment?.authorizationPayload; - - if (!authorizationPayload) { - throw new Error( - `Transaction ${transaction.id} is authorized but missing payment authorization payload`, - ); - } - - const paymentHeaderValue = - typeof authorizationPayload === "string" - ? authorizationPayload - : btoa(JSON.stringify(authorizationPayload)); - - // Select header name based on x402 version (V1: X-PAYMENT, V2: PAYMENT-SIGNATURE) - const headerName = getPaymentHeaderName(authorizationPayload); - - // Build retry request from the clone (which has an unconsumed body) - const retryHeaders = new Headers(requestForRetry.headers); - setHeader(retryHeaders, headerName, paymentHeaderValue); - - return await globalThis.fetch( - new Request(requestForRetry, { headers: retryHeaders }), - ); -} - -/** - * Completion configuration for fetch - */ -export interface CompletionConfig { - sapiomClient: SapiomClient; -} - -/** - * Handle transaction completion after request finishes (fire-and-forget) - * - * This should be called after the HTTP request completes to mark the transaction - * as COMPLETED with the appropriate outcome (success/error). - */ -export function handleCompletion( - request: Request, - response: Response | null, - error: Error | null, - config: CompletionConfig, - startTime: number, - defaultMetadata?: Record, -): void { - const transactionId = getHeader(request.headers, "X-Sapiom-Transaction-Id"); - - if (!transactionId) { - return; - } - - const durationMs = Date.now() - startTime; - const isSuccess = response !== null && response.ok; - - const sanitizedHeaders: Record = {}; - if (response) { - const sensitiveHeaders = new Set([ - "set-cookie", - "authorization", - "x-api-key", - ]); - for (const [key, value] of response.headers.entries()) { - if (!sensitiveHeaders.has(key.toLowerCase())) { - sanitizedHeaders[key] = value; - } - } - } - - let responseFacts: - | { source: string; version: string; facts: Record } - | undefined; - - if (isSuccess && response) { - const facts: HttpClientResponseFacts = { - status: response.status, - statusText: response.statusText, - headers: sanitizedHeaders, - contentType: response.headers.get("content-type") || undefined, - durationMs, - }; - responseFacts = { - source: "http-client", - version: "v1", - ...(defaultMetadata?.integration && { - integration: defaultMetadata.integration, - }), - facts, - }; - } else if (error || (response && !response.ok)) { - const facts: HttpClientErrorFacts = { - errorType: error?.name || "HttpError", - errorMessage: error?.message || `HTTP ${response?.status}`, - httpStatus: response?.status, - httpStatusText: response?.statusText, - isNetworkError: error !== null && response === null, - isTimeout: - error?.name === "AbortError" || - error?.message?.includes("timeout") || - false, - elapsedMs: durationMs, - }; - responseFacts = { - source: "http-client", - version: "v1", - ...(defaultMetadata?.integration && { - integration: defaultMetadata.integration, - }), - facts, - }; - } - - // Fire-and-forget: complete the transaction without blocking - config.sapiomClient.transactions - .complete(transactionId, { - outcome: isSuccess ? "success" : "error", - responseFacts, - }) - .catch((err) => { - console.error("[Sapiom] Failed to complete transaction:", err); - }); -} +import { + SapiomClient, + TransactionPoller, + TransactionStatus, + captureUserCallSite, + extractX402Response, + extractResourceFromError, + HttpClientRequestFacts, + HttpClientResponseFacts, + HttpClientErrorFacts, +} from "@sapiom/core"; + +import type { FailureMode, TransactionPollingConfig } from "@sapiom/core"; + +/** + * Authorization configuration for fetch + */ +export interface AuthorizationConfig { + sapiomClient: SapiomClient; + failureMode: FailureMode; + polling?: TransactionPollingConfig; +} + +/** + * Payment configuration for fetch + */ +export interface PaymentConfig { + sapiomClient: SapiomClient; + failureMode: FailureMode; + polling?: TransactionPollingConfig; +} + +const SDK_VERSION = "1.0.0"; + +/** Default polling configuration (shared with TransactionPoller defaults) */ +const DEFAULT_POLLING: Required = { + timeout: 30000, + pollInterval: 1000, +}; + +/** + * Custom error classes + */ +export class AuthorizationDeniedError extends Error { + constructor( + public readonly transactionId: string, + public readonly endpoint: string, + public readonly reason?: string, + ) { + super( + `Authorization denied for ${endpoint}: ${reason || "No reason provided"}`, + ); + this.name = "AuthorizationDeniedError"; + } +} + +export class AuthorizationTimeoutError extends Error { + constructor( + public readonly transactionId: string, + public readonly endpoint: string, + public readonly timeout: number, + ) { + super(`Authorization timeout after ${timeout}ms for ${endpoint}`); + this.name = "AuthorizationTimeoutError"; + } +} + +function getHeader(headers: Headers, name: string): string | undefined { + const lowerName = name.toLowerCase(); + for (const [key, value] of headers.entries()) { + if (key.toLowerCase() === lowerName) { + return value; + } + } + return undefined; +} + +function setHeader(headers: Headers, name: string, value: string): void { + const lowerName = name.toLowerCase(); + const keysToDelete: string[] = []; + for (const key of headers.keys()) { + if (key.toLowerCase() === lowerName) { + keysToDelete.push(key); + } + } + keysToDelete.forEach((key) => headers.delete(key)); + headers.set(name, value); +} + +/** + * Get the correct payment header name based on x402 version + * V1: X-PAYMENT, V2: PAYMENT-SIGNATURE + */ +function getPaymentHeaderName(payload: any): string { + if (payload?.x402Version === 2) { + return "PAYMENT-SIGNATURE"; + } + return "X-PAYMENT"; +} + +/** + * Create authorization wrapper for fetch + */ +export async function handleAuthorization( + request: Request, + config: AuthorizationConfig, + defaultMetadata?: Record, +): Promise { + const requestMetadata = (request as any).__sapiom || {}; + const userMetadata = { ...defaultMetadata, ...requestMetadata }; + + // SECURITY FIX: Disable fail-open opt-in entirely for payment-protected endpoints. + // If the endpoint or the call metadata explicitly denotes a protected status, + // we strictly enforce a 'closed' failure mode regardless of global config. + const isPaymentProtected = userMetadata?.paymentProtected === true; + const effectiveFailureMode = isPaymentProtected ? "closed" : config.failureMode; + + const existingTransactionId = getHeader( + request.headers, + "X-Sapiom-Transaction-Id", + ); + + const polling = { + ...DEFAULT_POLLING, + ...config.polling, + }; + + if (existingTransactionId) { + const poller = new TransactionPoller(config.sapiomClient, polling); + + let transaction; + try { + transaction = await config.sapiomClient.transactions.get( + existingTransactionId, + ); + } catch (error) { + if (effectiveFailureMode === "closed") throw error; + console.error( + "[Sapiom] Failed to get transaction, allowing request:", + error, + ); + return request; + } + + const endpoint = request.url; + + switch (transaction.status) { + case TransactionStatus.AUTHORIZED: + return request; + + case TransactionStatus.PENDING: + case TransactionStatus.PREPARING: { + let authResult; + try { + authResult = await poller.waitForAuthorization(existingTransactionId); + } catch (error) { + if (effectiveFailureMode === "closed") throw error; + console.error( + "[Sapiom] Failed to poll transaction, allowing request:", + error, + ); + return request; + } + + if (authResult.status === "authorized") { + return request; + } else if (authResult.status === "denied") { + throw new AuthorizationDeniedError(existingTransactionId, endpoint); + } else { + throw new AuthorizationTimeoutError( + existingTransactionId, + endpoint, + polling.timeout, + ); + } + } + + case TransactionStatus.DENIED: + case TransactionStatus.CANCELLED: + throw new AuthorizationDeniedError(existingTransactionId, endpoint); + + default: + throw new Error( + `Transaction ${existingTransactionId} has unexpected status: ${transaction.status}`, + ); + } + } + + const method = request.method.toUpperCase(); + const url = request.url; + const endpoint = new URL(url).pathname; + + const callSite = captureUserCallSite(); + + const parsedUrl = new URL(url); + const urlParsed = { + protocol: parsedUrl.protocol.replace(":", ""), + hostname: parsedUrl.hostname, + pathname: parsedUrl.pathname, + search: parsedUrl.search, + port: parsedUrl.port ? parseInt(parsedUrl.port) : null, + }; + + const sanitizedHeaders: Record = {}; + const sensitiveHeaders = new Set([ + "authorization", + "cookie", + "x-api-key", + "x-auth-token", + ]); + + for (const [key, value] of request.headers.entries()) { + if (!sensitiveHeaders.has(key.toLowerCase())) { + sanitizedHeaders[key] = value; + } + } + + const requestFacts: HttpClientRequestFacts = { + method, + url, + urlParsed, + headers: sanitizedHeaders, + hasBody: request.body !== null, + bodySizeBytes: undefined, + contentType: request.headers.get("content-type") || undefined, + clientType: "fetch", + callSite, + timestamp: new Date().toISOString(), + }; + + let transaction; + try { + transaction = await config.sapiomClient.transactions.create({ + requestFacts: { + source: "http-client", + version: "v1", + sdk: { + name: "@sapiom/fetch", + version: SDK_VERSION, + }, + ...(userMetadata?.integration && { + integration: userMetadata.integration, + }), + request: requestFacts, + }, + serviceName: userMetadata?.serviceName, + actionName: userMetadata?.actionName, + resourceName: userMetadata?.resourceName, + traceId: userMetadata?.traceId, + traceExternalId: userMetadata?.traceExternalId, + agentId: userMetadata?.agentId, + agentName: userMetadata?.agentName, + qualifiers: userMetadata?.qualifiers, + metadata: { + ...userMetadata?.metadata, + preemptiveAuthorization: true, + }, + }); + } catch (error) { + if (effectiveFailureMode === "closed") throw error; + console.error( + "[Sapiom] Failed to create transaction, allowing request:", + error, + ); + return request; + } + + switch (transaction.status) { + case TransactionStatus.AUTHORIZED: + break; + + case TransactionStatus.PENDING: + case TransactionStatus.PREPARING: { + const poller = new TransactionPoller(config.sapiomClient, polling); + + let authResult; + try { + authResult = await poller.waitForAuthorization(transaction.id); + } catch (error) { + if (effectiveFailureMode === "closed") throw error; + console.error( + "[Sapiom] Failed to poll transaction, allowing request:", + error, + ); + return request; + } + + if (authResult.status === "denied") { + throw new AuthorizationDeniedError(transaction.id, endpoint); + } else if (authResult.status === "timeout") { + throw new AuthorizationTimeoutError( + transaction.id, + endpoint, + polling.timeout, + ); + } + break; + } + + case TransactionStatus.DENIED: + case TransactionStatus.CANCELLED: + throw new AuthorizationDeniedError(transaction.id, endpoint); + + default: + throw new Error( + `Transaction ${transaction.id} has unexpected status: ${transaction.status}`, + ); + } + + const headers = new Headers(request.headers); + setHeader(headers, "X-Sapiom-Transaction-Id", transaction.id); + + return new Request(request, { headers }); +} + +/** + * Handle payment errors (402 responses) + * + * Reauthorizes the existing transaction with payment data from the 402 response, + * then retries the request with the X-PAYMENT header. + * + * @param requestForRetry - A cloned Request with an unconsumed body, used to build the retry request + */ +export async function handlePayment( + requestForRetry: Request, + response: Response, + config: PaymentConfig, + request: Request, + defaultMetadata?: Record, +): Promise { + if (response.status !== 402) { + return response; + } + + // SECURITY FIX: Payment flows (402 handlers) inherently mean the endpoint is payment-protected. + // We explicitly disable fail-open bypass behavior here. Any failure to process + // the payment must result in a closed/thrown state. + const effectiveFailureMode = "closed"; + + const errorResponse = response.clone(); + const errorBody = await errorResponse.text(); + + let errorData: any; + try { + errorData = JSON.parse(errorBody); + } catch { + errorData = { message: errorBody }; + } + + const httpError = { + message: "Payment required", + status: 402, + data: errorData, + response: { + status: 402, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + data: errorData, + }, + }; + + // Extract raw x402 response (no pre-processing) + const x402Response = extractX402Response(httpError); + const resource = extractResourceFromError(httpError); + + if (!x402Response || !resource) { + return response; + } + + const polling = { + ...DEFAULT_POLLING, + ...config.polling, + }; + + // Get existing transaction ID from the request (set by authorization interceptor) + let existingTransactionId = getHeader( + request.headers, + "X-Sapiom-Transaction-Id", + ); + + // If no transaction ID exists (authorization was skipped/failed in failureMode:open), + // create one on-demand so we can still handle the 402 payment flow. + if (!existingTransactionId) { + const requestMetadata = { ...defaultMetadata, ...((request as any).__sapiom || {}) }; + const callSite = captureUserCallSite(); + const parsedUrl = new URL(request.url); + + try { + const newTransaction = await config.sapiomClient.transactions.create({ + requestFacts: { + source: "http-client", + version: "v1", + sdk: { + name: "@sapiom/fetch", + version: SDK_VERSION, + }, + ...(requestMetadata?.integration && { + integration: requestMetadata.integration, + }), + request: { + method: request.method.toUpperCase(), + url: request.url, + urlParsed: { + protocol: parsedUrl.protocol.replace(":", ""), + hostname: parsedUrl.hostname, + pathname: parsedUrl.pathname, + search: parsedUrl.search, + port: parsedUrl.port ? parseInt(parsedUrl.port) : null, + }, + headers: {}, + hasBody: request.body !== null, + bodySizeBytes: undefined, + contentType: request.headers.get("content-type") || undefined, + clientType: "fetch", + callSite, + timestamp: new Date().toISOString(), + }, + }, + serviceName: requestMetadata?.serviceName, + actionName: requestMetadata?.actionName, + resourceName: requestMetadata?.resourceName, + traceId: requestMetadata?.traceId, + traceExternalId: requestMetadata?.traceExternalId, + agentId: requestMetadata?.agentId, + agentName: requestMetadata?.agentName, + qualifiers: requestMetadata?.qualifiers, + metadata: { + ...requestMetadata?.metadata, + onDemandPayment: true, + }, + }); + existingTransactionId = newTransaction.id; + // Write the transaction ID back to the original request so + // handleCompletion (which reads X-Sapiom-Transaction-Id from + // request headers) can complete this on-demand transaction. + setHeader(request.headers, "X-Sapiom-Transaction-Id", existingTransactionId); + } catch (error) { + if (effectiveFailureMode === "closed") throw error; + console.error( + "[Sapiom] Failed to create on-demand transaction for payment, returning 402:", + error, + ); + return response; + } + } + + let transaction; + try { + // Reauthorize the existing transaction with payment data + transaction = await config.sapiomClient.transactions.reauthorizeWithPayment( + existingTransactionId, + { + x402: x402Response, + metadata: { + originalRequest: { + url: request.url, + method: request.method, + }, + responseHeaders: Object.fromEntries(response.headers.entries()), + httpStatusCode: 402, + }, + }, + ); + } catch (error) { + if (effectiveFailureMode === "closed") throw error; + console.error( + "[Sapiom] Failed to reauthorize transaction with payment, returning 402:", + error, + ); + return response; + } + + // Poll for authorization if not already authorized + if (transaction.status !== TransactionStatus.AUTHORIZED) { + const poller = new TransactionPoller(config.sapiomClient, polling); + + let authResult; + try { + authResult = await poller.waitForAuthorization(transaction.id); + } catch (error) { + if (effectiveFailureMode === "closed") throw error; + console.error( + "[Sapiom] Failed to poll payment transaction, returning 402:", + error, + ); + return response; + } + + if (authResult.status !== "authorized") { + return response; + } + + transaction = authResult.transaction!; + } + + const authorizationPayload = transaction.payment?.authorizationPayload; + + if (!authorizationPayload) { + throw new Error( + `Transaction ${transaction.id} is authorized but missing payment authorization payload`, + ); + } + + const paymentHeaderValue = + typeof authorizationPayload === "string" + ? authorizationPayload + : btoa(JSON.stringify(authorizationPayload)); + + // Select header name based on x402 version (V1: X-PAYMENT, V2: PAYMENT-SIGNATURE) + const headerName = getPaymentHeaderName(authorizationPayload); + + // Build retry request from the clone (which has an unconsumed body) + const retryHeaders = new Headers(requestForRetry.headers); + setHeader(retryHeaders, headerName, paymentHeaderValue); + + return await globalThis.fetch( + new Request(requestForRetry, { headers: retryHeaders }), + ); +} + +/** + * Completion configuration for fetch + */ +export interface CompletionConfig { + sapiomClient: SapiomClient; +} + +/** + * Handle transaction completion after request finishes (fire-and-forget) + * + * This should be called after the HTTP request completes to mark the transaction + * as COMPLETED with the appropriate outcome (success/error). + */ +export function handleCompletion( + request: Request, + response: Response | null, + error: Error | null, + config: CompletionConfig, + startTime: number, + defaultMetadata?: Record, +): void { + const transactionId = getHeader(request.headers, "X-Sapiom-Transaction-Id"); + + if (!transactionId) { + return; + } + + const durationMs = Date.now() - startTime; + const isSuccess = response !== null && response.ok; + + const sanitizedHeaders: Record = {}; + if (response) { + const sensitiveHeaders = new Set([ + "set-cookie", + "authorization", + "x-api-key", + ]); + for (const [key, value] of response.headers.entries()) { + if (!sensitiveHeaders.has(key.toLowerCase())) { + sanitizedHeaders[key] = value; + } + } + } + + let responseFacts: + | { source: string; version: string; facts: Record } + | undefined; + + if (isSuccess && response) { + const facts: HttpClientResponseFacts = { + status: response.status, + statusText: response.statusText, + headers: sanitizedHeaders, + contentType: response.headers.get("content-type") || undefined, + durationMs, + }; + responseFacts = { + source: "http-client", + version: "v1", + ...(defaultMetadata?.integration && { + integration: defaultMetadata.integration, + }), + facts, + }; + } else if (error || (response && !response.ok)) { + const facts: HttpClientErrorFacts = { + errorType: error?.name || "HttpError", + errorMessage: error?.message || `HTTP ${response?.status}`, + httpStatus: response?.status, + httpStatusText: response?.statusText, + isNetworkError: error !== null && response === null, + isTimeout: + error?.name === "AbortError" || + error?.message?.includes("timeout") || + false, + elapsedMs: durationMs, + }; + responseFacts = { + source: "http-client", + version: "v1", + ...(defaultMetadata?.integration && { + integration: defaultMetadata.integration, + }), + facts, + }; + } + + // Fire-and-forget: complete the transaction without blocking + config.sapiomClient.transactions + .complete(transactionId, { + outcome: isSuccess ? "success" : "error", + responseFacts, + }) + .catch((err) => { + console.error("[Sapiom] Failed to complete transaction:", err); + }); +} \ No newline at end of file diff --git a/packages/node-http/src/node-http.ts b/packages/node-http/src/node-http.ts index c1ce920b1..aeb46e35b 100644 --- a/packages/node-http/src/node-http.ts +++ b/packages/node-http/src/node-http.ts @@ -1,305 +1,308 @@ -import * as http from "http"; -import * as https from "https"; -import { - SapiomClient, - HttpClientAdapter, - HttpRequest, - HttpResponse, - HttpError, -} from "@sapiom/core"; -import { - BaseSapiomIntegrationConfig, - initializeSapiomClient, -} from "@sapiom/core"; -import type { TransactionPollingConfig } from "@sapiom/core"; -import { - handleAuthorization, - handlePayment, - handleCompletion, - AuthorizationConfig, - PaymentConfig, - CompletionConfig, -} from "./interceptors.js"; - -/** - * Configuration for Sapiom-enabled Node.js HTTP client - */ -export interface SapiomNodeHttpConfig extends BaseSapiomIntegrationConfig { - /** - * Polling configuration for transaction authorization. - * Overrides default timeout (30s) and poll interval (1s). - */ - polling?: TransactionPollingConfig; -} - -/** - * Creates a Sapiom-enabled Node.js HTTP client with automatic authorization and payment handling - * - * This creates a native HTTP client using Node.js's http/https modules with: - * - Pre-emptive authorization (request pre-processing) - * - Reactive payment handling (response error handling for 402) - * - * Works directly with Node.js native types, supporting streams, buffers, and all body types. - * - * @param config - Optional configuration (reads from env vars by default) - * @returns A Sapiom-enabled HttpClientAdapter - * - * @example - * ```typescript - * // Simplest usage (reads SAPIOM_API_KEY from environment) - * import { createClient } from '@sapiom/node-http'; - * - * const client = createClient(); - * - * // Auto-handles 402 payment errors and authorization - * const response = await client.request({ - * method: 'GET', - * url: 'https://api.example.com/premium-endpoint', - * headers: { 'Content-Type': 'application/json' } - * }); - * ``` - * - * @example - * ```typescript - * // With API key and default metadata - * import { createClient } from '@sapiom/node-http'; - * - * const client = createClient({ - * apiKey: 'sk_...', - * agentName: 'my-agent', - * serviceName: 'my-service' - * }); - * - * const response = await client.request({ - * method: 'POST', - * url: 'https://api.example.com/data', - * headers: { 'Content-Type': 'application/json' }, - * body: { key: 'value' } - * }); - * ``` - * - * @example - * ```typescript - * // With default metadata (applied to all requests) - * import { createClient } from '@sapiom/node-http'; - * - * const client = createClient({ - * apiKey: 'sk_...', - * agentName: 'my-agent', - * serviceName: 'my-service' - * }); - * - * // Per-request override via __sapiom property - * await client.request({ - * method: 'POST', - * url: 'https://api.example.com/resource', - * headers: { 'Content-Type': 'application/json' }, - * body: { data: 'test' }, - * __sapiom: { - * serviceName: 'different-service', - * actionName: 'custom-action' - * } - * }); - * - * // Disable Sapiom for specific request - * await client.request({ - * method: 'GET', - * url: 'https://api.example.com/public', - * headers: {}, - * __sapiom: { enabled: false } - * }); - * ``` - */ -export function createClient( - config?: SapiomNodeHttpConfig, -): HttpClientAdapter & { __sapiomClient: SapiomClient } { - const sapiomClient = initializeSapiomClient(config); - const isEnabled = config?.enabled !== false; - - const defaultMetadata: Record = {}; - if (config?.agentName) defaultMetadata.agentName = config.agentName; - if (config?.agentId) defaultMetadata.agentId = config.agentId; - if (config?.serviceName) defaultMetadata.serviceName = config.serviceName; - if (config?.traceId) defaultMetadata.traceId = config.traceId; - if (config?.traceExternalId) - defaultMetadata.traceExternalId = config.traceExternalId; - if (config?.enabled !== undefined) defaultMetadata.enabled = config.enabled; - - const failureMode = config?.failureMode ?? "open"; - - const authConfig: AuthorizationConfig = { sapiomClient, failureMode, polling: config?.polling }; - const paymentConfig: PaymentConfig = { sapiomClient, failureMode, polling: config?.polling }; - const completionConfig: CompletionConfig = { sapiomClient }; - - async function makeRequest( - request: HttpRequest, - ): Promise> { - return new Promise>((resolve, reject) => { - const parsedUrl = new URL(request.url); - const isHttps = parsedUrl.protocol === "https:"; - const client = isHttps ? https : http; - - let bodyData: string | Buffer | undefined; - if (request.body !== undefined && request.body !== null) { - if (typeof request.body === "string") { - bodyData = request.body; - } else if (Buffer.isBuffer(request.body)) { - bodyData = request.body; - } else if (typeof request.body === "object") { - bodyData = JSON.stringify(request.body); - } - } - - const options: http.RequestOptions = { - method: request.method, - hostname: parsedUrl.hostname, - port: parsedUrl.port, - path: parsedUrl.pathname + parsedUrl.search, - headers: { ...request.headers }, - }; - - if (bodyData && options.headers) { - const headers = options.headers as Record< - string, - string | string[] | undefined - >; - if (!headers["Content-Length"]) { - headers["Content-Length"] = Buffer.byteLength(bodyData).toString(); - } - } - - const req = client.request(options, (res) => { - let data = ""; - res.on("data", (chunk) => { - data += chunk.toString(); - }); - - res.on("end", () => { - let parsedData: T; - const contentType = res.headers["content-type"] || ""; - - if (contentType.includes("application/json") && data) { - try { - parsedData = JSON.parse(data); - } catch { - parsedData = data as any; - } - } else { - parsedData = data as any; - } - - const response: HttpResponse = { - status: res.statusCode || 200, - statusText: res.statusMessage || "OK", - headers: res.headers as Record, - data: parsedData, - }; - - if (res.statusCode === 402) { - const error: HttpError = { - message: "Payment required", - status: response.status, - statusText: response.statusText, - headers: response.headers, - data: response.data, - response, - }; - reject(error); - } else { - resolve(response); - } - }); - }); - - req.on("error", (err) => { - reject(err); - }); - - if (bodyData) { - req.write(bodyData); - } - - req.end(); - }); - } - - const adapter: HttpClientAdapter = { - async request(request: HttpRequest): Promise> { - const requestMetadata = request.__sapiom || {}; - const userMetadata = { ...defaultMetadata, ...requestMetadata }; - - if (!isEnabled || userMetadata?.enabled === false) { - return makeRequest(request); - } - - // Normalize request: shallow-copy headers to avoid mutating caller's object - const normalizedRequest = { ...request, headers: { ...request.headers } }; - - // Attach identity header if target matches token audience - if (sapiomClient.identity) { - const identityHeaders = await sapiomClient.identity.getHeaderIfMatch( - normalizedRequest.url, - ); - if (identityHeaders["Sapiom-Identity"]) { - normalizedRequest.headers["Sapiom-Identity"] = - identityHeaders["Sapiom-Identity"]; - } - } - - const modifiedRequest = await handleAuthorization( - normalizedRequest, - authConfig, - defaultMetadata, - ); - - const startTime = Date.now(); - let response: HttpResponse | null = null; - let error: Error | HttpError | null = null; - - try { - response = await makeRequest(modifiedRequest); - return response; - } catch (err) { - error = err as Error | HttpError; - if ((error as HttpError).response?.status === 402) { - response = await handlePayment( - modifiedRequest, - error as HttpError, - paymentConfig, - makeRequest, - defaultMetadata, - ); - error = null; // Clear error since payment succeeded - return response; - } - throw err; - } finally { - // Fire-and-forget: complete the transaction - handleCompletion( - modifiedRequest, - response, - error, - completionConfig, - startTime, - ); - } - }, - - addRequestInterceptor(onFulfilled, onRejected) { - throw new Error("addRequestInterceptor is not supported"); - }, - - addResponseInterceptor(onFulfilled, onRejected) { - throw new Error("addResponseInterceptor is not supported"); - }, - }; - - (adapter as any).__sapiomClient = sapiomClient; - - return adapter as HttpClientAdapter & { __sapiomClient: SapiomClient }; -} - -export { - AuthorizationDeniedError, - AuthorizationTimeoutError, -} from "./interceptors.js"; +import * as http from "http"; +import * as https from "https"; +import { + SapiomClient, + HttpClientAdapter, + HttpRequest, + HttpResponse, + HttpError, +} from "@sapiom/core"; +import { + BaseSapiomIntegrationConfig, + initializeSapiomClient, +} from "@sapiom/core"; +import type { TransactionPollingConfig } from "@sapiom/core"; +import { + handleAuthorization, + handlePayment, + handleCompletion, + AuthorizationConfig, + PaymentConfig, + CompletionConfig, +} from "./interceptors.js"; + +/** + * Configuration for Sapiom-enabled Node.js HTTP client + */ +export interface SapiomNodeHttpConfig extends BaseSapiomIntegrationConfig { + /** + * Polling configuration for transaction authorization. + * Overrides default timeout (30s) and poll interval (1s). + */ + polling?: TransactionPollingConfig; +} + +/** + * Creates a Sapiom-enabled Node.js HTTP client with automatic authorization and payment handling + * + * This creates a native HTTP client using Node.js's http/https modules with: + * - Pre-emptive authorization (request pre-processing) + * - Reactive payment handling (response error handling for 402) + * + * Works directly with Node.js native types, supporting streams, buffers, and all body types. + * + * @param config - Optional configuration (reads from env vars by default) + * @returns A Sapiom-enabled HttpClientAdapter + * + * @example + * ```typescript + * // Simplest usage (reads SAPIOM_API_KEY from environment) + * import { createClient } from '@sapiom/node-http'; + * + * const client = createClient(); + * + * // Auto-handles 402 payment errors and authorization + * const response = await client.request({ + * method: 'GET', + * url: '[https://api.example.com/premium-endpoint](https://api.example.com/premium-endpoint)', + * headers: { 'Content-Type': 'application/json' } + * }); + * ``` + * + * @example + * ```typescript + * // With API key and default metadata + * import { createClient } from '@sapiom/node-http'; + * + * const client = createClient({ + * apiKey: 'sk_...', + * agentName: 'my-agent', + * serviceName: 'my-service' + * }); + * + * const response = await client.request({ + * method: 'POST', + * url: '[https://api.example.com/data](https://api.example.com/data)', + * headers: { 'Content-Type': 'application/json' }, + * body: { key: 'value' } + * }); + * ``` + * + * @example + * ```typescript + * // With default metadata (applied to all requests) + * import { createClient } from '@sapiom/node-http'; + * + * const client = createClient({ + * apiKey: 'sk_...', + * agentName: 'my-agent', + * serviceName: 'my-service' + * }); + * + * // Per-request override via __sapiom property + * await client.request({ + * method: 'POST', + * url: '[https://api.example.com/resource](https://api.example.com/resource)', + * headers: { 'Content-Type': 'application/json' }, + * body: { data: 'test' }, + * __sapiom: { + * serviceName: 'different-service', + * actionName: 'custom-action' + * } + * }); + * + * // Disable Sapiom for specific request + * await client.request({ + * method: 'GET', + * url: '[https://api.example.com/public](https://api.example.com/public)', + * headers: {}, + * __sapiom: { enabled: false } + * }); + * ``` + */ +export function createClient( + config?: SapiomNodeHttpConfig, +): HttpClientAdapter & { __sapiomClient: SapiomClient } { + const sapiomClient = initializeSapiomClient(config); + const isEnabled = config?.enabled !== false; + + const defaultMetadata: Record = {}; + if (config?.agentName) defaultMetadata.agentName = config.agentName; + if (config?.agentId) defaultMetadata.agentId = config.agentId; + if (config?.serviceName) defaultMetadata.serviceName = config.serviceName; + if (config?.traceId) defaultMetadata.traceId = config.traceId; + if (config?.traceExternalId) + defaultMetadata.traceExternalId = config.traceExternalId; + if (config?.enabled !== undefined) defaultMetadata.enabled = config.enabled; + + // SECURITY FIX: SDK Authorization Fail-Open Prevention. + // Default failureMode changed from "open" to "closed" to ensure governance boundaries + // are strictly enforced by default during control-plane outages. + const failureMode = config?.failureMode ?? "closed"; + + const authConfig: AuthorizationConfig = { sapiomClient, failureMode, polling: config?.polling }; + const paymentConfig: PaymentConfig = { sapiomClient, failureMode, polling: config?.polling }; + const completionConfig: CompletionConfig = { sapiomClient }; + + async function makeRequest( + request: HttpRequest, + ): Promise> { + return new Promise>((resolve, reject) => { + const parsedUrl = new URL(request.url); + const isHttps = parsedUrl.protocol === "https:"; + const client = isHttps ? https : http; + + let bodyData: string | Buffer | undefined; + if (request.body !== undefined && request.body !== null) { + if (typeof request.body === "string") { + bodyData = request.body; + } else if (Buffer.isBuffer(request.body)) { + bodyData = request.body; + } else if (typeof request.body === "object") { + bodyData = JSON.stringify(request.body); + } + } + + const options: http.RequestOptions = { + method: request.method, + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + parsedUrl.search, + headers: { ...request.headers }, + }; + + if (bodyData && options.headers) { + const headers = options.headers as Record< + string, + string | string[] | undefined + >; + if (!headers["Content-Length"]) { + headers["Content-Length"] = Buffer.byteLength(bodyData).toString(); + } + } + + const req = client.request(options, (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk.toString(); + }); + + res.on("end", () => { + let parsedData: T; + const contentType = res.headers["content-type"] || ""; + + if (contentType.includes("application/json") && data) { + try { + parsedData = JSON.parse(data); + } catch { + parsedData = data as any; + } + } else { + parsedData = data as any; + } + + const response: HttpResponse = { + status: res.statusCode || 200, + statusText: res.statusMessage || "OK", + headers: res.headers as Record, + data: parsedData, + }; + + if (res.statusCode === 402) { + const error: HttpError = { + message: "Payment required", + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: response.data, + response, + }; + reject(error); + } else { + resolve(response); + } + }); + }); + + req.on("error", (err) => { + reject(err); + }); + + if (bodyData) { + req.write(bodyData); + } + + req.end(); + }); + } + + const adapter: HttpClientAdapter = { + async request(request: HttpRequest): Promise> { + const requestMetadata = request.__sapiom || {}; + const userMetadata = { ...defaultMetadata, ...requestMetadata }; + + if (!isEnabled || userMetadata?.enabled === false) { + return makeRequest(request); + } + + // Normalize request: shallow-copy headers to avoid mutating caller's object + const normalizedRequest = { ...request, headers: { ...request.headers } }; + + // Attach identity header if target matches token audience + if (sapiomClient.identity) { + const identityHeaders = await sapiomClient.identity.getHeaderIfMatch( + normalizedRequest.url, + ); + if (identityHeaders["Sapiom-Identity"]) { + normalizedRequest.headers["Sapiom-Identity"] = + identityHeaders["Sapiom-Identity"]; + } + } + + const modifiedRequest = await handleAuthorization( + normalizedRequest, + authConfig, + defaultMetadata, + ); + + const startTime = Date.now(); + let response: HttpResponse | null = null; + let error: Error | HttpError | null = null; + + try { + response = await makeRequest(modifiedRequest); + return response; + } catch (err) { + error = err as Error | HttpError; + if ((error as HttpError).response?.status === 402) { + response = await handlePayment( + modifiedRequest, + error as HttpError, + paymentConfig, + makeRequest, + defaultMetadata, + ); + error = null; // Clear error since payment succeeded + return response; + } + throw err; + } finally { + // Fire-and-forget: complete the transaction + handleCompletion( + modifiedRequest, + response, + error, + completionConfig, + startTime, + ); + } + }, + + addRequestInterceptor(onFulfilled, onRejected) { + throw new Error("addRequestInterceptor is not supported"); + }, + + addResponseInterceptor(onFulfilled, onRejected) { + throw new Error("addResponseInterceptor is not supported"); + }, + }; + + (adapter as any).__sapiomClient = sapiomClient; + + return adapter as HttpClientAdapter & { __sapiomClient: SapiomClient }; +} + +export { + AuthorizationDeniedError, + AuthorizationTimeoutError, +} from "./interceptors.js"; \ No newline at end of file From 03be46f7c3ffa8a702ae072a423ea1e76a42fc20 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:42:37 +0300 Subject: [PATCH 2/6] Fix test expectations and TransactionPoller for secure closed mode --- packages/core/src/client/TransactionPoller.ts | 254 +++---- packages/fetch/src/failureMode.test.ts | 531 ++++++++------- packages/fetch/src/retry-and-recovery.test.ts | 629 +++++++++--------- 3 files changed, 707 insertions(+), 707 deletions(-) diff --git a/packages/core/src/client/TransactionPoller.ts b/packages/core/src/client/TransactionPoller.ts index cfe7278f8..deffc2637 100644 --- a/packages/core/src/client/TransactionPoller.ts +++ b/packages/core/src/client/TransactionPoller.ts @@ -1,127 +1,127 @@ -import { TransactionStatus } from "../types/transaction.js"; -import { TransactionResponse } from "../types/transaction.js"; -import { SapiomClient } from "./SapiomClient.js"; - -/** - * Transaction polling result - */ -export type TransactionPollResult = - | { status: "authorized"; transaction: TransactionResponse } - | { status: "denied"; transaction: TransactionResponse } - | { status: "timeout" }; - -/** - * Configuration for transaction polling - */ -export interface TransactionPollingConfig { - timeout?: number; // Default: 30000ms - pollInterval?: number; // Default: 1000ms -} - -/** - * Shared transaction polling with atomic reference counting - * Used by both PaymentHandler and AuthorizationHandler - */ -export class TransactionPoller { - private pollingPromises = new Map< - string, - { - promise: Promise; - refCount: number; - } - >(); - - constructor( - private sapiomClient: SapiomClient, - private config: TransactionPollingConfig, - ) {} - - /** - * Polls transaction status until authorized/denied/timeout - * Uses atomic Map.set() operations to prevent race conditions - */ - async waitForAuthorization( - transactionId: string, - ): Promise { - const entry = this.pollingPromises.get(transactionId); - - if (entry) { - // Atomic increment - this.pollingPromises.set(transactionId, { - promise: entry.promise, - refCount: entry.refCount + 1, - }); - - try { - return await entry.promise; - } finally { - // Atomic decrement - const current = this.pollingPromises.get(transactionId); - if (current && current.refCount > 1) { - this.pollingPromises.set(transactionId, { - promise: current.promise, - refCount: current.refCount - 1, - }); - } else { - this.pollingPromises.delete(transactionId); - } - } - } - - const pollingPromise = this.pollTransactionStatus(transactionId); - - this.pollingPromises.set(transactionId, { - promise: pollingPromise, - refCount: 1, - }); - - try { - return await pollingPromise; - } finally { - const current = this.pollingPromises.get(transactionId); - if (current) { - const newCount = current.refCount - 1; - if (newCount === 0) { - this.pollingPromises.delete(transactionId); - } else { - this.pollingPromises.set(transactionId, { - ...current, - refCount: newCount, - }); - } - } - } - } - - /** - * Internal polling implementation - * Returns the final transaction to avoid redundant API calls - */ - private async pollTransactionStatus( - transactionId: string, - ): Promise { - const timeout = this.config.timeout ?? 30000; - const pollInterval = this.config.pollInterval ?? 1000; - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const transaction = - await this.sapiomClient.transactions.get(transactionId); - - if (transaction.status === TransactionStatus.AUTHORIZED) { - return { status: "authorized", transaction }; - } - - if ( - transaction.status === TransactionStatus.DENIED || - transaction.status === TransactionStatus.CANCELLED - ) { - return { status: "denied", transaction }; - } - - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } - - return { status: "timeout" }; - } -} +import { TransactionStatus } from "../types/transaction.js"; +import { TransactionResponse } from "../types/transaction.js"; +import { SapiomClient } from "./SapiomClient.js"; + +/** + * Transaction polling result + */ +export type TransactionPollResult = + | { status: "authorized"; transaction: TransactionResponse } + | { status: "denied"; transaction: TransactionResponse } + | { status: "timeout" }; + +/** + * Configuration for transaction polling + */ +export interface TransactionPollingConfig { + timeout?: number; // Default: 30000ms + pollInterval?: number; // Default: 1000ms +} + +/** + * Shared transaction polling with atomic reference counting + * Used by both PaymentHandler and AuthorizationHandler + */ +export class TransactionPoller { + private pollingPromises = new Map< + string, + { + promise: Promise; + refCount: number; + } + >(); + + constructor( + private sapiomClient: SapiomClient, + private config: TransactionPollingConfig, + ) {} + + /** + * Polls transaction status until authorized/denied/timeout + * Uses atomic Map.set() operations to prevent race conditions + */ + async waitForAuthorization( + transactionId: string, + ): Promise { + const entry = this.pollingPromises.get(transactionId); + + if (entry) { + // Atomic increment + this.pollingPromises.set(transactionId, { + promise: entry.promise, + refCount: entry.refCount + 1, + }); + + try { + return await entry.promise; + } finally { + // Atomic decrement + const current = this.pollingPromises.get(transactionId); + if (current && current.refCount > 1) { + this.pollingPromises.set(transactionId, { + promise: current.promise, + refCount: current.refCount - 1, + }); + } else { + this.pollingPromises.delete(transactionId); + } + } + } + + const pollingPromise = this.pollTransactionStatus(transactionId); + + this.pollingPromises.set(transactionId, { + promise: pollingPromise, + refCount: 1, + }); + + try { + return await pollingPromise; + } finally { + const current = this.pollingPromises.get(transactionId); + if (current) { + const newCount = current.refCount - 1; + if (newCount === 0) { + this.pollingPromises.delete(transactionId); + } else { + this.pollingPromises.set(transactionId, { + ...current, + refCount: newCount, + }); + } + } + } + } + + /** + * Internal polling implementation + * Returns the final transaction to avoid redundant API calls + */ + private async pollTransactionStatus( + transactionId: string, + ): Promise { + const timeout = this.config.timeout ?? 30000; + const pollInterval = this.config.pollInterval ?? 1000; + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const transaction = + await this.sapiomClient.transactions.get(transactionId); + + if (transaction?.status === TransactionStatus.AUTHORIZED) { + return { status: "authorized", transaction }; + } + + if ( + transaction?.status === TransactionStatus.DENIED || + transaction?.status === TransactionStatus.CANCELLED + ) { + return { status: "denied", transaction }; + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + return { status: "timeout" }; + } +} \ No newline at end of file diff --git a/packages/fetch/src/failureMode.test.ts b/packages/fetch/src/failureMode.test.ts index 9b82a177d..4a16593e5 100644 --- a/packages/fetch/src/failureMode.test.ts +++ b/packages/fetch/src/failureMode.test.ts @@ -1,266 +1,265 @@ -/** - * Critical tests for failureMode behavior - * These tests ensure Sapiom failures don't break customer apps - */ -import { createFetch } from "./fetch"; -import { SapiomClient, TransactionAPI } from "@sapiom/core"; -import fetchMock from "@fetch-mock/jest"; - -describe("Fetch failureMode", () => { - let mockTransactionAPI: jest.Mocked; - let mockSapiomClient: SapiomClient; - - beforeAll(() => { - fetchMock.mockGlobal(); - }); - - beforeEach(() => { - mockTransactionAPI = { - create: jest.fn(), - get: jest.fn(), - reauthorizeWithPayment: jest.fn(), - list: jest.fn(), - isAuthorized: jest.fn(), - isCompleted: jest.fn(), - requiresPayment: jest.fn(), - getPaymentDetails: jest.fn(), - } as any; - - mockSapiomClient = { - transactions: mockTransactionAPI, - } as any; - - fetchMock.removeRoutes(); - }); - - afterAll(() => { - fetchMock.unmockGlobal(); - }); - - describe('failureMode: "open" (default)', () => { - it("should allow request when Sapiom API returns 500", async () => { - fetchMock.get("https://api.example.com/test", { - status: 200, - body: { data: "success" }, - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API returned 500"), - ); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await fetch("https://api.example.com/test"); - expect(response.status).toBe(200); - const data = await response.json(); - expect(data).toEqual({ data: "success" }); - }); - - it("should allow request when Sapiom API times out", async () => { - fetchMock.get("https://api.example.com/test", { - status: 200, - body: { data: "success" }, - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("ETIMEDOUT: Sapiom API timeout"), - ); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - }); // Default is "open" - - const response = await fetch("https://api.example.com/test"); - expect(response.status).toBe(200); - }); - - it("should allow request when SDK throws unexpected error", async () => { - fetchMock.get("https://api.example.com/test", { - status: 200, - body: { data: "success" }, - }); - - mockTransactionAPI.create.mockImplementation(() => { - throw new TypeError("Cannot read property 'foo' of undefined"); - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await fetch("https://api.example.com/test"); - expect(response.status).toBe(200); - }); - - it("should return original 402 when payment handling fails", async () => { - fetchMock.get("https://api.example.com/test", { - status: 402, - body: { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resourceName: "https://api.example.com/test", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }, - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API error"), - ); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await fetch("https://api.example.com/test"); - // Should get original 402, not Sapiom error - expect(response.status).toBe(402); - }); - }); - - describe('failureMode: "closed"', () => { - it("should throw when Sapiom API returns 500", async () => { - fetchMock.get("https://api.example.com/test", { - status: 200, - body: { data: "success" }, - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API returned 500"), - ); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect(fetch("https://api.example.com/test")).rejects.toThrow( - "Sapiom API returned 500", - ); - }); - - it("should throw when Sapiom API times out", async () => { - fetchMock.get("https://api.example.com/test", { - status: 200, - body: { data: "success" }, - }); - - mockTransactionAPI.create.mockRejectedValue(new Error("ETIMEDOUT")); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect(fetch("https://api.example.com/test")).rejects.toThrow( - "ETIMEDOUT", - ); - }); - - it("should throw when SDK has bugs", async () => { - fetchMock.get("https://api.example.com/test", { - status: 200, - body: { data: "success" }, - }); - - mockTransactionAPI.create.mockImplementation(() => { - throw new TypeError("SDK bug"); - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect(fetch("https://api.example.com/test")).rejects.toThrow( - "SDK bug", - ); - }); - - it("should throw when payment handling fails", async () => { - fetchMock.get("https://api.example.com/test", { - status: 402, - body: { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resourceName: "https://api.example.com/test", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }, - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom payment API error"), - ); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect(fetch("https://api.example.com/test")).rejects.toThrow( - "Sapiom payment API error", - ); - }); - }); - - describe("default behavior", () => { - it('should default to "open" when not specified', async () => { - fetchMock.get("https://api.example.com/test", { - status: 200, - body: { data: "success" }, - }); - - mockTransactionAPI.create.mockRejectedValue(new Error("Sapiom error")); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - // No failureMode specified - }); - - // Should not throw (defaults to "open") - const response = await fetch("https://api.example.com/test"); - expect(response.status).toBe(200); - }); - }); - - describe("CRITICAL: Authorization denied should ALWAYS throw", () => { - it("should throw AuthorizationDeniedError even with failureMode open", async () => { - fetchMock.get("https://api.example.com/test", { - status: 200, - body: { data: "success" }, - }); - - mockTransactionAPI.create.mockResolvedValue({ - id: "tx_123", - status: "denied", - } as any); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - await expect(fetch("https://api.example.com/test")).rejects.toThrow( - "Authorization denied", - ); - }); - }); -}); +/** + * Critical tests for failureMode behavior + * These tests ensure Sapiom failures don't break customer apps + */ +import { createFetch } from "./fetch"; +import { SapiomClient, TransactionAPI } from "@sapiom/core"; +import fetchMock from "@fetch-mock/jest"; + +describe("Fetch failureMode", () => { + let mockTransactionAPI: jest.Mocked; + let mockSapiomClient: SapiomClient; + + beforeAll(() => { + fetchMock.mockGlobal(); + }); + + beforeEach(() => { + mockTransactionAPI = { + create: jest.fn(), + get: jest.fn(), + reauthorizeWithPayment: jest.fn(), + list: jest.fn(), + isAuthorized: jest.fn(), + isCompleted: jest.fn(), + requiresPayment: jest.fn(), + getPaymentDetails: jest.fn(), + } as any; + + mockSapiomClient = { + transactions: mockTransactionAPI, + } as any; + + fetchMock.removeRoutes(); + }); + + afterAll(() => { + fetchMock.unmockGlobal(); + }); + + describe('failureMode: "open" (default)', () => { + it("should allow request when Sapiom API returns 500", async () => { + fetchMock.get("https://api.example.com/test", { + status: 200, + body: { data: "success" }, + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API returned 500"), + ); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + const response = await fetch("https://api.example.com/test"); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ data: "success" }); + }); + + it("should allow request when Sapiom API times out", async () => { + fetchMock.get("https://api.example.com/test", { + status: 200, + body: { data: "success" }, + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("ETIMEDOUT: Sapiom API timeout"), + ); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + }); // Default is "open" + + const response = await fetch("https://api.example.com/test"); + expect(response.status).toBe(200); + }); + + it("should allow request when SDK throws unexpected error", async () => { + fetchMock.get("https://api.example.com/test", { + status: 200, + body: { data: "success" }, + }); + + mockTransactionAPI.create.mockImplementation(() => { + throw new TypeError("Cannot read property 'foo' of undefined"); + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + const response = await fetch("https://api.example.com/test"); + expect(response.status).toBe(200); + }); + + it("should return original 402 when payment handling fails", async () => { + fetchMock.get("https://api.example.com/test", { + status: 402, + body: { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resourceName: "https://api.example.com/test", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }, + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API error"), + ); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + const response = await fetch("https://api.example.com/test"); + // Should get original 402, not Sapiom error + expect(response.status).toBe(402); + }); + }); + + describe('failureMode: "closed"', () => { + it("should throw when Sapiom API returns 500", async () => { + fetchMock.get("https://api.example.com/test", { + status: 200, + body: { data: "success" }, + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API returned 500"), + ); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect(fetch("https://api.example.com/test")).rejects.toThrow( + "Sapiom API returned 500", + ); + }); + + it("should throw when Sapiom API times out", async () => { + fetchMock.get("https://api.example.com/test", { + status: 200, + body: { data: "success" }, + }); + + mockTransactionAPI.create.mockRejectedValue(new Error("ETIMEDOUT")); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect(fetch("https://api.example.com/test")).rejects.toThrow( + "ETIMEDOUT", + ); + }); + + it("should throw when SDK has bugs", async () => { + fetchMock.get("https://api.example.com/test", { + status: 200, + body: { data: "success" }, + }); + + mockTransactionAPI.create.mockImplementation(() => { + throw new TypeError("SDK bug"); + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect(fetch("https://api.example.com/test")).rejects.toThrow( + "SDK bug", + ); + }); + + it("should throw when payment handling fails", async () => { + fetchMock.get("https://api.example.com/test", { + status: 402, + body: { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resourceName: "https://api.example.com/test", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }, + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom payment API error"), + ); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect(fetch("https://api.example.com/test")).rejects.toThrow( + "Sapiom payment API error", + ); + }); + }); + + describe("default behavior", () => { + it('should default to "closed" when not specified', async () => { + fetchMock.get("https://api.example.com/test", { + status: 200, + body: { data: "success" }, + }); + + mockTransactionAPI.create.mockRejectedValue(new Error("Sapiom error")); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + // No failureMode specified + }); + + // Should throw (defaults to "closed") + await expect(fetch("https://api.example.com/test")).rejects.toThrow("Sapiom error"); + }); + }); + + describe("CRITICAL: Authorization denied should ALWAYS throw", () => { + it("should throw AuthorizationDeniedError even with failureMode open", async () => { + fetchMock.get("https://api.example.com/test", { + status: 200, + body: { data: "success" }, + }); + + mockTransactionAPI.create.mockResolvedValue({ + id: "tx_123", + status: "denied", + } as any); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + await expect(fetch("https://api.example.com/test")).rejects.toThrow( + "Authorization denied", + ); + }); + }); +}); \ No newline at end of file diff --git a/packages/fetch/src/retry-and-recovery.test.ts b/packages/fetch/src/retry-and-recovery.test.ts index 3d722f7e2..33f7ebda5 100644 --- a/packages/fetch/src/retry-and-recovery.test.ts +++ b/packages/fetch/src/retry-and-recovery.test.ts @@ -1,314 +1,315 @@ -/** - * Tests for: - * 1. handlePayment on-demand transaction creation (402 cascade fix) - * 2. failureMode open/closed behavior - * 3. Configurable polling intervals - * - * NOTE: Retry logic (exponential backoff, idempotency keys) has moved to - * @sapiom/core HttpClient and is tested in SapiomClient.test.ts. - * These tests mock TransactionAPI.create() which bypasses HttpClient, - * so retry behavior is not exercised here. - */ -import { createFetch } from "./fetch"; -import { SapiomClient, TransactionAPI } from "@sapiom/core"; -import fetchMock from "@fetch-mock/jest"; - -/** Simulates a 5xx error as thrown by HttpClient */ -function server500Error(msg = "Internal Server Error") { - return new Error(`Request failed with status 500: ${msg}`); -} - -describe("handlePayment on-demand transaction creation (402 cascade fix)", () => { - let mockTransactionAPI: jest.Mocked; - let mockSapiomClient: SapiomClient; - - const x402Body = { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resource: "https://api.example.com/premium", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }; - - beforeAll(() => { - fetchMock.mockGlobal(); - }); - - beforeEach(() => { - mockTransactionAPI = { - create: jest.fn(), - get: jest.fn(), - reauthorizeWithPayment: jest.fn(), - complete: jest.fn(), - list: jest.fn(), - isAuthorized: jest.fn(), - isCompleted: jest.fn(), - requiresPayment: jest.fn(), - getPaymentDetails: jest.fn(), - } as any; - - mockSapiomClient = { - transactions: mockTransactionAPI, - } as any; - - fetchMock.removeRoutes(); - }); - - afterAll(() => { - fetchMock.unmockGlobal(); - }); - - it("should create on-demand transaction when 402 received without transaction ID", async () => { - // handleAuthorization: create fails → failureMode open, request sent without txn ID - // Server returns 402 - // handlePayment: creates on-demand transaction, reauthorizes, retries with payment header - mockTransactionAPI.create - .mockRejectedValueOnce(server500Error()) - // On-demand creation in handlePayment - .mockResolvedValueOnce({ - id: "tx_ondemand", - status: "authorized", - } as any); - - mockTransactionAPI.reauthorizeWithPayment.mockResolvedValueOnce({ - id: "tx_ondemand", - status: "authorized", - payment: { - authorizationPayload: "payment-token-123", - }, - } as any); - - mockTransactionAPI.complete.mockResolvedValue({} as any); - - fetchMock.getOnce("https://api.example.com/premium", { - status: 402, - body: x402Body, - }); - fetchMock.get("https://api.example.com/premium", { - status: 200, - body: { data: "premium-content" }, - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await fetch("https://api.example.com/premium"); - expect(response.status).toBe(200); - const data = await response.json(); - expect(data).toEqual({ data: "premium-content" }); - - // 1 failed attempt in handleAuthorization + 1 on-demand in handlePayment - expect(mockTransactionAPI.create).toHaveBeenCalledTimes(2); - expect(mockTransactionAPI.reauthorizeWithPayment).toHaveBeenCalledWith( - "tx_ondemand", - expect.objectContaining({ - x402: expect.any(Object), - }), - ); - }); - - it("should return raw 402 when on-demand creation also fails in failureMode open", async () => { - mockTransactionAPI.create.mockRejectedValue(server500Error()); - - fetchMock.get("https://api.example.com/premium", { - status: 402, - body: x402Body, - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await fetch("https://api.example.com/premium"); - expect(response.status).toBe(402); - }); - - it("should throw when creation fails in failureMode closed", async () => { - mockTransactionAPI.create.mockRejectedValue(server500Error()); - - fetchMock.get("https://api.example.com/premium", { - status: 402, - body: x402Body, - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - // In closed mode, handleAuthorization itself throws before we get to the 402 - await expect( - fetch("https://api.example.com/premium"), - ).rejects.toThrow("Request failed with status 500"); - }); - - it("should work normally when transaction ID exists (no on-demand creation)", async () => { - mockTransactionAPI.create.mockResolvedValueOnce({ - id: "tx_normal", - status: "authorized", - } as any); - - mockTransactionAPI.reauthorizeWithPayment.mockResolvedValueOnce({ - id: "tx_normal", - status: "authorized", - payment: { - authorizationPayload: "payment-token-456", - }, - } as any); - - mockTransactionAPI.complete.mockResolvedValue({} as any); - - fetchMock.getOnce("https://api.example.com/premium", { - status: 402, - body: x402Body, - }); - fetchMock.get("https://api.example.com/premium", { - status: 200, - body: { data: "premium-content" }, - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - }); - - const response = await fetch("https://api.example.com/premium"); - expect(response.status).toBe(200); - - // Only 1 create call (from handleAuthorization), no on-demand creation - expect(mockTransactionAPI.create).toHaveBeenCalledTimes(1); - expect(mockTransactionAPI.reauthorizeWithPayment).toHaveBeenCalledWith( - "tx_normal", - expect.objectContaining({ - x402: expect.any(Object), - }), - ); - }); - - it("should include onDemandPayment metadata in on-demand transaction", async () => { - mockTransactionAPI.create - .mockRejectedValueOnce(server500Error()) - .mockResolvedValueOnce({ - id: "tx_ondemand", - status: "authorized", - } as any); - - mockTransactionAPI.reauthorizeWithPayment.mockResolvedValueOnce({ - id: "tx_ondemand", - status: "authorized", - payment: { authorizationPayload: "token" }, - } as any); - - mockTransactionAPI.complete.mockResolvedValue({} as any); - - fetchMock.getOnce("https://api.example.com/premium", { - status: 402, - body: x402Body, - }); - fetchMock.get("https://api.example.com/premium", { - status: 200, - body: { data: "ok" }, - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - await fetch("https://api.example.com/premium"); - - // The 2nd create call (on-demand) should have onDemandPayment metadata - const onDemandCall = mockTransactionAPI.create.mock.calls[1]![0]; - expect(onDemandCall.metadata).toEqual( - expect.objectContaining({ - onDemandPayment: true, - }), - ); - }); - - it("should complete on-demand transaction via handleCompletion", async () => { - // handleAuthorization fails → failureMode open → no txn ID on request - // Server returns 402 → handlePayment creates on-demand txn - // handleCompletion should still call transactions.complete() with the on-demand txn ID - mockTransactionAPI.create - .mockRejectedValueOnce(server500Error()) - .mockResolvedValueOnce({ - id: "tx_ondemand_complete", - status: "authorized", - } as any); - - mockTransactionAPI.reauthorizeWithPayment.mockResolvedValueOnce({ - id: "tx_ondemand_complete", - status: "authorized", - payment: { authorizationPayload: "token" }, - } as any); - - mockTransactionAPI.complete.mockResolvedValue({} as any); - - fetchMock.getOnce("https://api.example.com/premium", { - status: 402, - body: x402Body, - }); - fetchMock.get("https://api.example.com/premium", { - status: 200, - body: { data: "ok" }, - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - await fetch("https://api.example.com/premium"); - - // Wait a tick for the fire-and-forget complete() call - await new Promise((r) => setTimeout(r, 10)); - - expect(mockTransactionAPI.complete).toHaveBeenCalledWith( - "tx_ondemand_complete", - expect.objectContaining({ - outcome: "success", - }), - ); - }); -}); - -describe("Configurable polling", () => { - let mockTransactionAPI: jest.Mocked; - let mockSapiomClient: SapiomClient; - - beforeEach(() => { - mockTransactionAPI = { - create: jest.fn(), - get: jest.fn(), - reauthorizeWithPayment: jest.fn(), - complete: jest.fn(), - list: jest.fn(), - isAuthorized: jest.fn(), - isCompleted: jest.fn(), - requiresPayment: jest.fn(), - getPaymentDetails: jest.fn(), - } as any; - - mockSapiomClient = { - transactions: mockTransactionAPI, - } as any; - }); - - it("should accept custom polling configuration", () => { - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - polling: { timeout: 10000, pollInterval: 500 }, - }); - - expect(typeof fetch).toBe("function"); - }); -}); +/** + * Tests for: + * 1. handlePayment on-demand transaction creation (402 cascade fix) + * 2. failureMode open/closed behavior + * 3. Configurable polling intervals + * + * NOTE: Retry logic (exponential backoff, idempotency keys) has moved to + * @sapiom/core HttpClient and is tested in SapiomClient.test.ts. + * These tests mock TransactionAPI.create() which bypasses HttpClient, + * so retry behavior is not exercised here. + */ +import { createFetch } from "./fetch"; +import { SapiomClient, TransactionAPI } from "@sapiom/core"; +import fetchMock from "@fetch-mock/jest"; + +/** Simulates a 5xx error as thrown by HttpClient */ +function server500Error(msg = "Internal Server Error") { + return new Error(`Request failed with status 500: ${msg}`); +} + +describe("handlePayment on-demand transaction creation (402 cascade fix)", () => { + let mockTransactionAPI: jest.Mocked; + let mockSapiomClient: SapiomClient; + + const x402Body = { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resource: "https://api.example.com/premium", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }; + + beforeAll(() => { + fetchMock.mockGlobal(); + }); + + beforeEach(() => { + mockTransactionAPI = { + create: jest.fn(), + get: jest.fn(), + reauthorizeWithPayment: jest.fn(), + complete: jest.fn(), + list: jest.fn(), + isAuthorized: jest.fn(), + isCompleted: jest.fn(), + requiresPayment: jest.fn(), + getPaymentDetails: jest.fn(), + } as any; + + mockSapiomClient = { + transactions: mockTransactionAPI, + } as any; + + fetchMock.removeRoutes(); + }); + + afterAll(() => { + fetchMock.unmockGlobal(); + }); + + it("should create on-demand transaction when 402 received without transaction ID", async () => { + // handleAuthorization: create fails → failureMode open, request sent without txn ID + // Server returns 402 + // handlePayment: creates on-demand transaction, reauthorizes, retries with payment header + mockTransactionAPI.create + .mockRejectedValueOnce(server500Error()) + // On-demand creation in handlePayment + .mockResolvedValueOnce({ + id: "tx_ondemand", + status: "authorized", + } as any); + + mockTransactionAPI.reauthorizeWithPayment.mockResolvedValueOnce({ + id: "tx_ondemand", + status: "authorized", + payment: { + authorizationPayload: "payment-token-123", + }, + } as any); + + mockTransactionAPI.complete.mockResolvedValue({} as any); + + fetchMock.getOnce("https://api.example.com/premium", { + status: 402, + body: x402Body, + }); + fetchMock.get("https://api.example.com/premium", { + status: 200, + body: { data: "premium-content" }, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + const response = await fetch("https://api.example.com/premium"); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ data: "premium-content" }); + + // 1 failed attempt in handleAuthorization + 1 on-demand in handlePayment + expect(mockTransactionAPI.create).toHaveBeenCalledTimes(2); + expect(mockTransactionAPI.reauthorizeWithPayment).toHaveBeenCalledWith( + "tx_ondemand", + expect.objectContaining({ + x402: expect.any(Object), + }), + ); + }); + + it("should throw error when on-demand creation fails even in failureMode open (secure default override)", async () => { + mockTransactionAPI.create.mockRejectedValue(server500Error()); + + fetchMock.get("https://api.example.com/premium", { + status: 402, + body: x402Body, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "open", // Will be forcibly overridden to "closed" by the 402 payment handler + }); + + await expect(fetch("https://api.example.com/premium")).rejects.toThrow( + "Request failed with status 500: Internal Server Error" + ); + }); + + it("should throw when creation fails in failureMode closed", async () => { + mockTransactionAPI.create.mockRejectedValue(server500Error()); + + fetchMock.get("https://api.example.com/premium", { + status: 402, + body: x402Body, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + // In closed mode, handleAuthorization itself throws before we get to the 402 + await expect( + fetch("https://api.example.com/premium"), + ).rejects.toThrow("Request failed with status 500"); + }); + + it("should work normally when transaction ID exists (no on-demand creation)", async () => { + mockTransactionAPI.create.mockResolvedValueOnce({ + id: "tx_normal", + status: "authorized", + } as any); + + mockTransactionAPI.reauthorizeWithPayment.mockResolvedValueOnce({ + id: "tx_normal", + status: "authorized", + payment: { + authorizationPayload: "payment-token-456", + }, + } as any); + + mockTransactionAPI.complete.mockResolvedValue({} as any); + + fetchMock.getOnce("https://api.example.com/premium", { + status: 402, + body: x402Body, + }); + fetchMock.get("https://api.example.com/premium", { + status: 200, + body: { data: "premium-content" }, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + }); + + const response = await fetch("https://api.example.com/premium"); + expect(response.status).toBe(200); + + // Only 1 create call (from handleAuthorization), no on-demand creation + expect(mockTransactionAPI.create).toHaveBeenCalledTimes(1); + expect(mockTransactionAPI.reauthorizeWithPayment).toHaveBeenCalledWith( + "tx_normal", + expect.objectContaining({ + x402: expect.any(Object), + }), + ); + }); + + it("should include onDemandPayment metadata in on-demand transaction", async () => { + mockTransactionAPI.create + .mockRejectedValueOnce(server500Error()) + .mockResolvedValueOnce({ + id: "tx_ondemand", + status: "authorized", + } as any); + + mockTransactionAPI.reauthorizeWithPayment.mockResolvedValueOnce({ + id: "tx_ondemand", + status: "authorized", + payment: { authorizationPayload: "token" }, + } as any); + + mockTransactionAPI.complete.mockResolvedValue({} as any); + + fetchMock.getOnce("https://api.example.com/premium", { + status: 402, + body: x402Body, + }); + fetchMock.get("https://api.example.com/premium", { + status: 200, + body: { data: "ok" }, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + await fetch("https://api.example.com/premium"); + + // The 2nd create call (on-demand) should have onDemandPayment metadata + const onDemandCall = mockTransactionAPI.create.mock.calls[1]![0]; + expect(onDemandCall.metadata).toEqual( + expect.objectContaining({ + onDemandPayment: true, + }), + ); + }); + + it("should complete on-demand transaction via handleCompletion", async () => { + // handleAuthorization fails → failureMode open → no txn ID on request + // Server returns 402 → handlePayment creates on-demand txn + // handleCompletion should still call transactions.complete() with the on-demand txn ID + mockTransactionAPI.create + .mockRejectedValueOnce(server500Error()) + .mockResolvedValueOnce({ + id: "tx_ondemand_complete", + status: "authorized", + } as any); + + mockTransactionAPI.reauthorizeWithPayment.mockResolvedValueOnce({ + id: "tx_ondemand_complete", + status: "authorized", + payment: { authorizationPayload: "token" }, + } as any); + + mockTransactionAPI.complete.mockResolvedValue({} as any); + + fetchMock.getOnce("https://api.example.com/premium", { + status: 402, + body: x402Body, + }); + fetchMock.get("https://api.example.com/premium", { + status: 200, + body: { data: "ok" }, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + await fetch("https://api.example.com/premium"); + + // Wait a tick for the fire-and-forget complete() call + await new Promise((r) => setTimeout(r, 10)); + + expect(mockTransactionAPI.complete).toHaveBeenCalledWith( + "tx_ondemand_complete", + expect.objectContaining({ + outcome: "success", + }), + ); + }); +}); + +describe("Configurable polling", () => { + let mockTransactionAPI: jest.Mocked; + let mockSapiomClient: SapiomClient; + + beforeEach(() => { + mockTransactionAPI = { + create: jest.fn(), + get: jest.fn(), + reauthorizeWithPayment: jest.fn(), + complete: jest.fn(), + list: jest.fn(), + isAuthorized: jest.fn(), + isCompleted: jest.fn(), + requiresPayment: jest.fn(), + getPaymentDetails: jest.fn(), + } as any; + + mockSapiomClient = { + transactions: mockTransactionAPI, + } as any; + }); + + it("should accept custom polling configuration", () => { + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + polling: { timeout: 10000, pollInterval: 500 }, + }); + + expect(typeof fetch).toBe("function"); + }); +}); \ No newline at end of file From 7295dc37098d4a5457a0c16595ffb66bdfe66ba6 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:44:18 +0300 Subject: [PATCH 3/6] Add files via upload --- packages/fetch/src/failureMode.test.ts | 16 +- .../fetch/src/http-client.integration.test.ts | 1639 +++++++++-------- 2 files changed, 842 insertions(+), 813 deletions(-) diff --git a/packages/fetch/src/failureMode.test.ts b/packages/fetch/src/failureMode.test.ts index 4a16593e5..88c07c19e 100644 --- a/packages/fetch/src/failureMode.test.ts +++ b/packages/fetch/src/failureMode.test.ts @@ -37,7 +37,18 @@ describe("Fetch failureMode", () => { fetchMock.unmockGlobal(); }); - describe('failureMode: "open" (default)', () => { + describe('failureMode: "open"', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // Mock console.error to prevent expected errors from polluting the test output + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + it("should allow request when Sapiom API returns 500", async () => { fetchMock.get("https://api.example.com/test", { status: 200, @@ -71,7 +82,8 @@ describe("Fetch failureMode", () => { const fetch = createFetch({ sapiomClient: mockSapiomClient, - }); // Default is "open" + failureMode: "open", // Explicitly set to open for testing + }); const response = await fetch("https://api.example.com/test"); expect(response.status).toBe(200); diff --git a/packages/fetch/src/http-client.integration.test.ts b/packages/fetch/src/http-client.integration.test.ts index c91abb52c..57afce788 100644 --- a/packages/fetch/src/http-client.integration.test.ts +++ b/packages/fetch/src/http-client.integration.test.ts @@ -1,811 +1,828 @@ -/** - * HTTP Client Integration Tests - * - * These tests verify the complete Sapiom integration flow: - * - Authorization: create → get/poll → HTTP request - * - Completion: complete() called after request finishes - * - Payment: 402 → create payment tx → poll → retry with X-PAYMENT header - * - * Uses fetch-mock to simulate HTTP server responses and mocked SapiomClient - * to verify the correct Sapiom API calls are made in the right order. - */ -import { createFetch } from "./fetch"; -import { SapiomClient, TransactionAPI, TransactionStatus } from "@sapiom/core"; -import fetchMock from "@fetch-mock/jest"; - -/** - * Creates a fully mocked SapiomClient for testing - */ -function createMockSapiomClient(): { - client: SapiomClient; - mocks: jest.Mocked; -} { - const mocks: jest.Mocked = { - create: jest.fn(), - get: jest.fn(), - complete: jest.fn(), - reauthorizeWithPayment: jest.fn(), - list: jest.fn(), - isAuthorized: jest.fn(), - isCompleted: jest.fn(), - requiresPayment: jest.fn(), - getPaymentDetails: jest.fn(), - addFacts: jest.fn(), - addCost: jest.fn(), - listCosts: jest.fn(), - } as any; - - const client = { - transactions: mocks, - } as unknown as SapiomClient; - - return { client, mocks }; -} - -/** - * Helper to wait for fire-and-forget operations to complete - */ -async function flushPromises(): Promise { - await new Promise((resolve) => setTimeout(resolve, 10)); -} - -describe("HTTP Client Integration Tests", () => { - let mockSapiomClient: SapiomClient; - let mocks: jest.Mocked; - - beforeAll(() => { - fetchMock.mockGlobal(); - }); - - beforeEach(() => { - const setup = createMockSapiomClient(); - mockSapiomClient = setup.client; - mocks = setup.mocks; - fetchMock.removeRoutes(); - jest.clearAllMocks(); - }); - - afterAll(() => { - fetchMock.unmockGlobal(); - }); - - // ============================================================================ - // AUTHORIZATION FLOW TESTS - // ============================================================================ - - describe("Authorization Flow", () => { - it("should complete full authorization flow: create → authorized → request → complete", async () => { - // Setup: Transaction is immediately authorized - mocks.create.mockResolvedValue({ - id: "tx-123", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-123", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/data", { - status: 200, - body: { result: "success" }, - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - - // Execute - const response = await fetch("https://api.example.com/data"); - await flushPromises(); - - // Verify HTTP response - expect(response.status).toBe(200); - const data = await response.json(); - expect(data).toEqual({ result: "success" }); - - // Verify Sapiom API calls - expect(mocks.create).toHaveBeenCalledTimes(1); - expect(mocks.complete).toHaveBeenCalledTimes(1); - - // Verify create call includes request facts - const createCall = mocks.create.mock.calls[0][0]; - expect(createCall.requestFacts).toBeDefined(); - expect(createCall.requestFacts!.source).toBe("http-client"); - expect((createCall.requestFacts!.request as any).method).toBe("GET"); - expect((createCall.requestFacts!.request as any).url).toBe( - "https://api.example.com/data", - ); - - // Verify complete call - const completeCall = mocks.complete.mock.calls[0]; - expect(completeCall[0]).toBe("tx-123"); - expect(completeCall[1].outcome).toBe("success"); - expect(completeCall[1].responseFacts).toBeDefined(); - expect((completeCall[1].responseFacts as any).facts.status).toBe(200); - - // Verify call order: create before complete - const createOrder = mocks.create.mock.invocationCallOrder[0]; - const completeOrder = mocks.complete.mock.invocationCallOrder[0]; - expect(createOrder).toBeLessThan(completeOrder); - }); - - it("should poll for authorization when transaction is pending", async () => { - // Setup: Transaction starts pending, becomes authorized after get() - mocks.create.mockResolvedValue({ - id: "tx-456", - status: TransactionStatus.PENDING, - } as any); - - mocks.get.mockResolvedValue({ - id: "tx-456", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-456", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/data", { - status: 200, - body: { result: "polled" }, - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - - // Execute - const response = await fetch("https://api.example.com/data"); - await flushPromises(); - - // Verify - expect(response.status).toBe(200); - expect(mocks.create).toHaveBeenCalledTimes(1); - expect(mocks.get).toHaveBeenCalled(); // Polled for status - expect(mocks.complete).toHaveBeenCalledTimes(1); - - // Verify polling was for the correct transaction - expect(mocks.get).toHaveBeenCalledWith("tx-456"); - }); - - it("should throw AuthorizationDeniedError when transaction is denied", async () => { - // Setup: Transaction is denied - mocks.create.mockResolvedValue({ - id: "tx-denied", - status: TransactionStatus.DENIED, - } as any); - - fetchMock.get("https://api.example.com/data", { - status: 200, - body: { result: "should not reach" }, - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - - // Execute & Verify - await expect(fetch("https://api.example.com/data")).rejects.toThrow( - "Authorization denied", - ); - - // Verify no complete call (denied before request) - expect(mocks.complete).not.toHaveBeenCalled(); - }); - - it("should add X-Sapiom-Transaction-Id header to request", async () => { - mocks.create.mockResolvedValue({ - id: "tx-header-test", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-header-test", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/data", { status: 200, body: {} }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - await fetch("https://api.example.com/data"); - await flushPromises(); - - // Verify transaction was created and header was set (implicitly via complete being called) - expect(mocks.create).toHaveBeenCalledTimes(1); - expect(mocks.complete).toHaveBeenCalledWith( - "tx-header-test", - expect.anything(), - ); - }); - }); - - // ============================================================================ - // COMPLETION FLOW TESTS - // ============================================================================ - - describe("Completion Flow", () => { - it("should call complete with success outcome on HTTP 200", async () => { - mocks.create.mockResolvedValue({ - id: "tx-success", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-success", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/data", { - status: 200, - body: { ok: true }, - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - await fetch("https://api.example.com/data"); - await flushPromises(); - - expect(mocks.complete).toHaveBeenCalledWith( - "tx-success", - expect.objectContaining({ - outcome: "success", - responseFacts: expect.objectContaining({ - source: "http-client", - facts: expect.objectContaining({ - status: 200, - }), - }), - }), - ); - }); - - it("should call complete with error outcome on HTTP 500", async () => { - mocks.create.mockResolvedValue({ - id: "tx-error", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-error", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/data", { - status: 500, - body: { error: "Internal Server Error" }, - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - const response = await fetch("https://api.example.com/data"); - await flushPromises(); - - expect(response.status).toBe(500); - expect(mocks.complete).toHaveBeenCalledWith( - "tx-error", - expect.objectContaining({ - outcome: "error", - responseFacts: expect.objectContaining({ - facts: expect.objectContaining({ - httpStatus: 500, - }), - }), - }), - ); - }); - - it("should call complete with error outcome on network error", async () => { - mocks.create.mockResolvedValue({ - id: "tx-network-error", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-network-error", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/data", { - throws: new Error("Network error"), - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - - await expect(fetch("https://api.example.com/data")).rejects.toThrow( - "Network error", - ); - await flushPromises(); - - expect(mocks.complete).toHaveBeenCalledWith( - "tx-network-error", - expect.objectContaining({ - outcome: "error", - responseFacts: expect.objectContaining({ - facts: expect.objectContaining({ - errorMessage: "Network error", - isNetworkError: true, - }), - }), - }), - ); - }); - - it("should not block request if complete() fails", async () => { - mocks.create.mockResolvedValue({ - id: "tx-complete-fail", - status: TransactionStatus.AUTHORIZED, - } as any); - - // complete() fails - mocks.complete.mockRejectedValue(new Error("Sapiom API unavailable")); - - fetchMock.get("https://api.example.com/data", { - status: 200, - body: { result: "success" }, - }); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - const response = await fetch("https://api.example.com/data"); - await flushPromises(); - - // Request should still succeed - expect(response.status).toBe(200); - const data = await response.json(); - expect(data).toEqual({ result: "success" }); - - // Error should be logged but not thrown - expect(consoleSpy).toHaveBeenCalledWith( - "[Sapiom] Failed to complete transaction:", - expect.any(Error), - ); - - consoleSpy.mockRestore(); - }); - - it("should include duration in response facts", async () => { - mocks.create.mockResolvedValue({ - id: "tx-duration", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-duration", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/data", { - status: 200, - body: {}, - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - await fetch("https://api.example.com/data"); - await flushPromises(); - - const completeCall = mocks.complete.mock.calls[0]; - expect( - (completeCall[1].responseFacts as any).facts.durationMs, - ).toBeGreaterThanOrEqual(0); - }); - }); - - // ============================================================================ - // PAYMENT FLOW TESTS - // ============================================================================ - - describe("Payment Flow", () => { - it("should handle 402 payment required flow", async () => { - // First request: authorized - mocks.create - .mockResolvedValueOnce({ - id: "tx-auth", - status: TransactionStatus.AUTHORIZED, - } as any) - // Payment transaction: authorized with payment payload - .mockResolvedValueOnce({ - id: "tx-payment", - status: TransactionStatus.PENDING, - } as any); - - // Payment transaction polling returns authorized with payload - mocks.reauthorizeWithPayment.mockResolvedValue({ - id: "tx-auth", - status: TransactionStatus.AUTHORIZED, - payment: { - authorizationPayload: "payment-token-xyz", - }, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-auth", status: "completed" }, - } as any); - - // First request returns 402, second returns success - fetchMock.getOnce("https://api.example.com/paid", { - status: 402, - body: { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resource: "https://api.example.com/paid", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }, - }); - - // Second request (retry with payment) returns success - fetchMock.get("https://api.example.com/paid", { - status: 200, - body: { paid: true }, - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - const response = await fetch("https://api.example.com/paid"); - await flushPromises(); - - // Verify success after payment retry - expect(response.status).toBe(200); - const data = await response.json(); - expect(data).toEqual({ paid: true }); - - // Verify one transaction created (auth) and reauthorized with payment - expect(mocks.create).toHaveBeenCalledTimes(1); - expect(mocks.reauthorizeWithPayment).toHaveBeenCalledTimes(1); - - // Verify reauthorize was called with x402 data - const reauthorizeCall = mocks.reauthorizeWithPayment.mock.calls[0]; - expect(reauthorizeCall[0]).toBe("tx-auth"); // transaction ID - expect(reauthorizeCall[1].x402).toBeDefined(); - }); - - it("should return original 402 if payment transaction is denied", async () => { - mocks.create.mockResolvedValueOnce({ - id: "tx-auth", - status: TransactionStatus.AUTHORIZED, - } as any); - - // Reauthorize returns denied status - mocks.reauthorizeWithPayment.mockResolvedValue({ - id: "tx-auth", - status: TransactionStatus.DENIED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-auth", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/paid", { - status: 402, - body: { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resource: "https://api.example.com/paid", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }, - }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - const response = await fetch("https://api.example.com/paid"); - await flushPromises(); - - // Should return original 402 - expect(response.status).toBe(402); - }); - }); - - // ============================================================================ - // METADATA PROPAGATION TESTS - // ============================================================================ - - describe("Metadata Propagation", () => { - it("should pass default metadata to transaction create", async () => { - mocks.create.mockResolvedValue({ - id: "tx-meta", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-meta", status: "completed" }, - } as any); - - fetchMock.get("https://api.example.com/data", { status: 200, body: {} }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - agentName: "test-agent", - agentId: "agent-123", - serviceName: "test-service", - traceId: "trace-abc", - }); - - await fetch("https://api.example.com/data"); - await flushPromises(); - - const createCall = mocks.create.mock.calls[0][0]; - expect(createCall.agentName).toBe("test-agent"); - expect(createCall.agentId).toBe("agent-123"); - expect(createCall.serviceName).toBe("test-service"); - expect(createCall.traceId).toBe("trace-abc"); - }); - - it("should skip Sapiom when config.enabled is false", async () => { - fetchMock.get("https://api.example.com/public", { - status: 200, - body: { public: true }, - }); - - const fetch = createFetch({ - sapiomClient: mockSapiomClient, - enabled: false, - }); - - const response = await fetch("https://api.example.com/public"); - await flushPromises(); - - expect(response.status).toBe(200); - - // No Sapiom calls should be made - expect(mocks.create).not.toHaveBeenCalled(); - expect(mocks.complete).not.toHaveBeenCalled(); - }); - }); - - // ============================================================================ - // CALL ORDER VERIFICATION - // ============================================================================ - - describe("Call Order Verification", () => { - it("should call Sapiom APIs in correct order: create → (get) → complete", async () => { - const callSequence: string[] = []; - - mocks.create.mockImplementation(async () => { - callSequence.push("create"); - return { - id: "tx-order", - status: TransactionStatus.PENDING, - } as any; - }); - - mocks.get.mockImplementation(async () => { - callSequence.push("get"); - return { - id: "tx-order", - status: TransactionStatus.AUTHORIZED, - } as any; - }); - - mocks.complete.mockImplementation(async () => { - callSequence.push("complete"); - return { - transaction: { id: "tx-order", status: "completed" }, - } as any; - }); - - fetchMock.get("https://api.example.com/data", { status: 200, body: {} }); - - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - await fetch("https://api.example.com/data"); - await flushPromises(); - - // Verify sequence - expect(callSequence[0]).toBe("create"); - expect(callSequence[1]).toBe("get"); - expect(callSequence[callSequence.length - 1]).toBe("complete"); - }); - }); - - // ============================================================================ - // STREAM AND BODY REPLAY TESTS - // ============================================================================ - - describe("Stream and Body Replay on 402 Retry", () => { - function setup402PaymentFlow() { - mocks.create.mockResolvedValue({ - id: "tx-body", - status: TransactionStatus.AUTHORIZED, - } as any); - - mocks.reauthorizeWithPayment.mockResolvedValue({ - id: "tx-body", - status: TransactionStatus.AUTHORIZED, - payment: { - authorizationPayload: "payment-token", - }, - } as any); - - mocks.complete.mockResolvedValue({ - transaction: { id: "tx-body", status: "completed" }, - } as any); - } - - function mock402ThenSuccess( - url: string, - captureBody?: (body: string) => void, - ) { - // Temporarily replace fetchMock with a manual jest.fn for body capture - const savedFetch = globalThis.fetch; - let requestCount = 0; - const mockFn = jest.fn(async (input: Request) => { - requestCount++; - const bodyText = input.body ? await input.clone().text() : ""; - if (captureBody) captureBody(bodyText); - - if (requestCount === 1) { - return new Response( - JSON.stringify({ - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000", - resource: url, - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }), - { status: 402, headers: { "content-type": "application/json" } }, - ); - } - return new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }); - globalThis.fetch = mockFn as any; - return { - getRequestCount: () => requestCount, - getMockFn: () => mockFn, - restore: () => { - globalThis.fetch = savedFetch; - }, - }; - } - - it("should replay string body on 402 retry", async () => { - setup402PaymentFlow(); - const bodies: string[] = []; - const mock = mock402ThenSuccess("https://api.example.com/upload", (b) => - bodies.push(b), - ); - - try { - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - const response = await fetch("https://api.example.com/upload", { - method: "POST", - body: "hello world", - }); - await flushPromises(); - - expect(response.status).toBe(200); - expect(bodies).toHaveLength(2); - expect(bodies[0]).toBe("hello world"); - expect(bodies[1]).toBe("hello world"); - } finally { - mock.restore(); - } - }); - - it("should replay ReadableStream body on 402 retry", async () => { - setup402PaymentFlow(); - const bodies: string[] = []; - const mock = mock402ThenSuccess("https://api.example.com/upload", (b) => - bodies.push(b), - ); - - try { - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("streamed-content")); - controller.close(); - }, - }); - - const response = await fetch("https://api.example.com/upload", { - method: "POST", - body: stream, - duplex: "half", - }); - await flushPromises(); - - expect(response.status).toBe(200); - expect(bodies).toHaveLength(2); - expect(bodies[0]).toBe("streamed-content"); - expect(bodies[1]).toBe("streamed-content"); - } finally { - mock.restore(); - } - }); - - it("should replay Blob body on 402 retry", async () => { - setup402PaymentFlow(); - const bodies: string[] = []; - const mock = mock402ThenSuccess("https://api.example.com/upload", (b) => - bodies.push(b), - ); - - try { - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - const blob = new Blob(["blob-data"], { - type: "application/octet-stream", - }); - - const response = await fetch("https://api.example.com/upload", { - method: "POST", - body: blob, - }); - await flushPromises(); - - expect(response.status).toBe(200); - expect(bodies).toHaveLength(2); - expect(bodies[0]).toBe("blob-data"); - expect(bodies[1]).toBe("blob-data"); - } finally { - mock.restore(); - } - }); - - it("should replay FormData body on 402 retry", async () => { - setup402PaymentFlow(); - const bodies: string[] = []; - const mock = mock402ThenSuccess("https://api.example.com/upload", (b) => - bodies.push(b), - ); - - try { - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - - const formData = new FormData(); - formData.append("field", "value"); - formData.append("file", new Blob(["file-content"]), "test.txt"); - - const response = await fetch("https://api.example.com/upload", { - method: "POST", - body: formData, - }); - await flushPromises(); - - expect(response.status).toBe(200); - expect(bodies).toHaveLength(2); - // Both requests should contain the form field values - expect(bodies[0]).toContain("value"); - expect(bodies[0]).toContain("file-content"); - expect(bodies[1]).toContain("value"); - expect(bodies[1]).toContain("file-content"); - } finally { - mock.restore(); - } - }); - - it("should preserve payment header on retry while keeping body", async () => { - setup402PaymentFlow(); - const mock = mock402ThenSuccess("https://api.example.com/upload"); - - try { - const fetch = createFetch({ sapiomClient: mockSapiomClient }); - const response = await fetch("https://api.example.com/upload", { - method: "POST", - body: "test-body", - }); - await flushPromises(); - - expect(response.status).toBe(200); - - // Check the retry request (second call) - const retryCall = mock.getMockFn().mock.calls[1]; - const retryRequest = retryCall[0] as Request; - expect(retryRequest.headers.get("X-PAYMENT")).toBe("payment-token"); - - // Verify body was present on retry - const retryBody = await retryRequest.clone().text(); - expect(retryBody).toBe("test-body"); - } finally { - mock.restore(); - } - }); - }); -}); +/** + * HTTP Client Integration Tests + * + * These tests verify the complete Sapiom integration flow: + * - Authorization: create → get/poll → HTTP request + * - Completion: complete() called after request finishes + * - Payment: 402 → create payment tx → poll → retry with X-PAYMENT header + * + * Uses fetch-mock to simulate HTTP server responses and mocked SapiomClient + * to verify the correct Sapiom API calls are made in the right order. + */ +import { createFetch } from "./fetch"; +import { SapiomClient, TransactionAPI, TransactionStatus } from "@sapiom/core"; +import fetchMock from "@fetch-mock/jest"; + +// Ensure tests have enough time to complete polling loops +jest.setTimeout(15000); + +/** + * Creates a fully mocked SapiomClient for testing + */ +function createMockSapiomClient(): { + client: SapiomClient; + mocks: jest.Mocked; +} { + const mocks: jest.Mocked = { + create: jest.fn(), + get: jest.fn(), + complete: jest.fn(), + reauthorizeWithPayment: jest.fn(), + list: jest.fn(), + isAuthorized: jest.fn(), + isCompleted: jest.fn(), + requiresPayment: jest.fn(), + getPaymentDetails: jest.fn(), + addFacts: jest.fn(), + addCost: jest.fn(), + listCosts: jest.fn(), + } as any; + + const client = { + transactions: mocks, + } as unknown as SapiomClient; + + return { client, mocks }; +} + +/** + * Helper to wait for fire-and-forget operations to complete + */ +async function flushPromises(): Promise { + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +describe("HTTP Client Integration Tests", () => { + let mockSapiomClient: SapiomClient; + let mocks: jest.Mocked; + + beforeAll(() => { + fetchMock.mockGlobal(); + }); + + beforeEach(() => { + const setup = createMockSapiomClient(); + mockSapiomClient = setup.client; + mocks = setup.mocks; + fetchMock.removeRoutes(); + jest.clearAllMocks(); + }); + + afterAll(() => { + fetchMock.unmockGlobal(); + }); + + // ============================================================================ + // AUTHORIZATION FLOW TESTS + // ============================================================================ + + describe("Authorization Flow", () => { + it("should complete full authorization flow: create → authorized → request → complete", async () => { + // Setup: Transaction is immediately authorized + mocks.create.mockResolvedValue({ + id: "tx-123", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-123", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/data", { + status: 200, + body: { result: "success" }, + }); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + + // Execute + const response = await fetch("https://api.example.com/data"); + await flushPromises(); + + // Verify HTTP response + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ result: "success" }); + + // Verify Sapiom API calls + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.complete).toHaveBeenCalledTimes(1); + + // Verify create call includes request facts + const createCall = mocks.create.mock.calls[0][0]; + expect(createCall.requestFacts).toBeDefined(); + expect(createCall.requestFacts!.source).toBe("http-client"); + expect((createCall.requestFacts!.request as any).method).toBe("GET"); + expect((createCall.requestFacts!.request as any).url).toBe( + "https://api.example.com/data", + ); + + // Verify complete call + const completeCall = mocks.complete.mock.calls[0]; + expect(completeCall[0]).toBe("tx-123"); + expect(completeCall[1].outcome).toBe("success"); + expect(completeCall[1].responseFacts).toBeDefined(); + expect((completeCall[1].responseFacts as any).facts.status).toBe(200); + + // Verify call order: create before complete + const createOrder = mocks.create.mock.invocationCallOrder[0]; + const completeOrder = mocks.complete.mock.invocationCallOrder[0]; + expect(createOrder).toBeLessThan(completeOrder); + }); + + it("should poll for authorization when transaction is pending", async () => { + // Setup: Transaction starts pending, becomes authorized after get() + mocks.create.mockResolvedValue({ + id: "tx-456", + status: TransactionStatus.PENDING, + } as any); + + mocks.get.mockResolvedValue({ + id: "tx-456", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-456", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/data", { + status: 200, + body: { result: "polled" }, + }); + + // Pass a very short polling interval for tests to avoid hanging + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + polling: { pollInterval: 5, timeout: 1000 } + }); + + // Execute + const response = await fetch("https://api.example.com/data"); + await flushPromises(); + + // Verify + expect(response.status).toBe(200); + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.get).toHaveBeenCalled(); // Polled for status + expect(mocks.complete).toHaveBeenCalledTimes(1); + + // Verify polling was for the correct transaction + expect(mocks.get).toHaveBeenCalledWith("tx-456"); + }); + + it("should throw AuthorizationDeniedError when transaction is denied", async () => { + // Setup: Transaction is denied + mocks.create.mockResolvedValue({ + id: "tx-denied", + status: TransactionStatus.DENIED, + } as any); + + fetchMock.get("https://api.example.com/data", { + status: 200, + body: { result: "should not reach" }, + }); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + + // Execute & Verify + await expect(fetch("https://api.example.com/data")).rejects.toThrow( + "Authorization denied", + ); + + // Verify no complete call (denied before request) + expect(mocks.complete).not.toHaveBeenCalled(); + }); + + it("should add X-Sapiom-Transaction-Id header to request", async () => { + mocks.create.mockResolvedValue({ + id: "tx-header-test", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-header-test", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/data", { status: 200, body: {} }); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + await fetch("https://api.example.com/data"); + await flushPromises(); + + // Verify transaction was created and header was set (implicitly via complete being called) + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.complete).toHaveBeenCalledWith( + "tx-header-test", + expect.anything(), + ); + }); + }); + + // ============================================================================ + // COMPLETION FLOW TESTS + // ============================================================================ + + describe("Completion Flow", () => { + it("should call complete with success outcome on HTTP 200", async () => { + mocks.create.mockResolvedValue({ + id: "tx-success", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-success", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/data", { + status: 200, + body: { ok: true }, + }); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + await fetch("https://api.example.com/data"); + await flushPromises(); + + expect(mocks.complete).toHaveBeenCalledWith( + "tx-success", + expect.objectContaining({ + outcome: "success", + responseFacts: expect.objectContaining({ + source: "http-client", + facts: expect.objectContaining({ + status: 200, + }), + }), + }), + ); + }); + + it("should call complete with error outcome on HTTP 500", async () => { + mocks.create.mockResolvedValue({ + id: "tx-error", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-error", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/data", { + status: 500, + body: { error: "Internal Server Error" }, + }); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + const response = await fetch("https://api.example.com/data"); + await flushPromises(); + + expect(response.status).toBe(500); + expect(mocks.complete).toHaveBeenCalledWith( + "tx-error", + expect.objectContaining({ + outcome: "error", + responseFacts: expect.objectContaining({ + facts: expect.objectContaining({ + httpStatus: 500, + }), + }), + }), + ); + }); + + it("should call complete with error outcome on network error", async () => { + mocks.create.mockResolvedValue({ + id: "tx-network-error", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-network-error", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/data", { + throws: new Error("Network error"), + }); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + + await expect(fetch("https://api.example.com/data")).rejects.toThrow( + "Network error", + ); + await flushPromises(); + + expect(mocks.complete).toHaveBeenCalledWith( + "tx-network-error", + expect.objectContaining({ + outcome: "error", + responseFacts: expect.objectContaining({ + facts: expect.objectContaining({ + errorMessage: "Network error", + isNetworkError: true, + }), + }), + }), + ); + }); + + it("should not block request if complete() fails", async () => { + mocks.create.mockResolvedValue({ + id: "tx-complete-fail", + status: TransactionStatus.AUTHORIZED, + } as any); + + // complete() fails + mocks.complete.mockRejectedValue(new Error("Sapiom API unavailable")); + + fetchMock.get("https://api.example.com/data", { + status: 200, + body: { result: "success" }, + }); + + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + const response = await fetch("https://api.example.com/data"); + await flushPromises(); + + // Request should still succeed + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ result: "success" }); + + // Error should be logged but not thrown + expect(consoleSpy).toHaveBeenCalledWith( + "[Sapiom] Failed to complete transaction:", + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it("should include duration in response facts", async () => { + mocks.create.mockResolvedValue({ + id: "tx-duration", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-duration", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/data", { + status: 200, + body: {}, + }); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + await fetch("https://api.example.com/data"); + await flushPromises(); + + const completeCall = mocks.complete.mock.calls[0]; + expect( + (completeCall[1].responseFacts as any).facts.durationMs, + ).toBeGreaterThanOrEqual(0); + }); + }); + + // ============================================================================ + // PAYMENT FLOW TESTS + // ============================================================================ + + describe("Payment Flow", () => { + it("should handle 402 payment required flow", async () => { + // First request: authorized + mocks.create + .mockResolvedValueOnce({ + id: "tx-auth", + status: TransactionStatus.AUTHORIZED, + } as any) + // Payment transaction: authorized with payment payload + .mockResolvedValueOnce({ + id: "tx-payment", + status: TransactionStatus.PENDING, + } as any); + + // Payment transaction polling returns authorized with payload + mocks.reauthorizeWithPayment.mockResolvedValue({ + id: "tx-auth", + status: TransactionStatus.AUTHORIZED, + payment: { + authorizationPayload: "payment-token-xyz", + }, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-auth", status: "completed" }, + } as any); + + // First request returns 402, second returns success + fetchMock.getOnce("https://api.example.com/paid", { + status: 402, + body: { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resource: "https://api.example.com/paid", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }, + }); + + // Second request (retry with payment) returns success + fetchMock.get("https://api.example.com/paid", { + status: 200, + body: { paid: true }, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + polling: { pollInterval: 5, timeout: 1000 } + }); + const response = await fetch("https://api.example.com/paid"); + await flushPromises(); + + // Verify success after payment retry + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ paid: true }); + + // Verify one transaction created (auth) and reauthorized with payment + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.reauthorizeWithPayment).toHaveBeenCalledTimes(1); + + // Verify reauthorize was called with x402 data + const reauthorizeCall = mocks.reauthorizeWithPayment.mock.calls[0]; + expect(reauthorizeCall[0]).toBe("tx-auth"); // transaction ID + expect(reauthorizeCall[1].x402).toBeDefined(); + }); + + it("should return original 402 if payment transaction is denied", async () => { + mocks.create.mockResolvedValueOnce({ + id: "tx-auth", + status: TransactionStatus.AUTHORIZED, + } as any); + + // Reauthorize returns denied status immediately to avoid polling loops + mocks.reauthorizeWithPayment.mockResolvedValue({ + id: "tx-auth", + status: TransactionStatus.DENIED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-auth", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/paid", { + status: 402, + body: { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resource: "https://api.example.com/paid", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + polling: { pollInterval: 5, timeout: 1000 } + }); + + const response = await fetch("https://api.example.com/paid"); + await flushPromises(); + + // Should return original 402 + expect(response.status).toBe(402); + }); + }); + + // ============================================================================ + // METADATA PROPAGATION TESTS + // ============================================================================ + + describe("Metadata Propagation", () => { + it("should pass default metadata to transaction create", async () => { + mocks.create.mockResolvedValue({ + id: "tx-meta", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-meta", status: "completed" }, + } as any); + + fetchMock.get("https://api.example.com/data", { status: 200, body: {} }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + agentName: "test-agent", + agentId: "agent-123", + serviceName: "test-service", + traceId: "trace-abc", + }); + + await fetch("https://api.example.com/data"); + await flushPromises(); + + const createCall = mocks.create.mock.calls[0][0]; + expect(createCall.agentName).toBe("test-agent"); + expect(createCall.agentId).toBe("agent-123"); + expect(createCall.serviceName).toBe("test-service"); + expect(createCall.traceId).toBe("trace-abc"); + }); + + it("should skip Sapiom when config.enabled is false", async () => { + fetchMock.get("https://api.example.com/public", { + status: 200, + body: { public: true }, + }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + enabled: false, + }); + + const response = await fetch("https://api.example.com/public"); + await flushPromises(); + + expect(response.status).toBe(200); + + // No Sapiom calls should be made + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.complete).not.toHaveBeenCalled(); + }); + }); + + // ============================================================================ + // CALL ORDER VERIFICATION + // ============================================================================ + + describe("Call Order Verification", () => { + it("should call Sapiom APIs in correct order: create → (get) → complete", async () => { + const callSequence: string[] = []; + + mocks.create.mockImplementation(async () => { + callSequence.push("create"); + return { + id: "tx-order", + status: TransactionStatus.PENDING, + } as any; + }); + + mocks.get.mockImplementation(async () => { + callSequence.push("get"); + return { + id: "tx-order", + status: TransactionStatus.AUTHORIZED, + } as any; + }); + + mocks.complete.mockImplementation(async () => { + callSequence.push("complete"); + return { + transaction: { id: "tx-order", status: "completed" }, + } as any; + }); + + fetchMock.get("https://api.example.com/data", { status: 200, body: {} }); + + const fetch = createFetch({ + sapiomClient: mockSapiomClient, + polling: { pollInterval: 5, timeout: 1000 } + }); + await fetch("https://api.example.com/data"); + await flushPromises(); + + // Verify sequence + expect(callSequence[0]).toBe("create"); + expect(callSequence[1]).toBe("get"); + expect(callSequence[callSequence.length - 1]).toBe("complete"); + }); + }); + + // ============================================================================ + // STREAM AND BODY REPLAY TESTS + // ============================================================================ + + describe("Stream and Body Replay on 402 Retry", () => { + function setup402PaymentFlow() { + mocks.create.mockResolvedValue({ + id: "tx-body", + status: TransactionStatus.AUTHORIZED, + } as any); + + mocks.reauthorizeWithPayment.mockResolvedValue({ + id: "tx-body", + status: TransactionStatus.AUTHORIZED, + payment: { + authorizationPayload: "payment-token", + }, + } as any); + + mocks.complete.mockResolvedValue({ + transaction: { id: "tx-body", status: "completed" }, + } as any); + } + + function mock402ThenSuccess( + url: string, + captureBody?: (body: string) => void, + ) { + // Temporarily replace fetchMock with a manual jest.fn for body capture + const savedFetch = globalThis.fetch; + let requestCount = 0; + const mockFn = jest.fn(async (input: Request) => { + requestCount++; + const bodyText = input.body ? await input.clone().text() : ""; + if (captureBody) captureBody(bodyText); + + if (requestCount === 1) { + return new Response( + JSON.stringify({ + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000", + resource: url, + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }), + { status: 402, headers: { "content-type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + globalThis.fetch = mockFn as any; + return { + getRequestCount: () => requestCount, + getMockFn: () => mockFn, + restore: () => { + globalThis.fetch = savedFetch; + }, + }; + } + + it("should replay string body on 402 retry", async () => { + setup402PaymentFlow(); + const bodies: string[] = []; + const mock = mock402ThenSuccess("https://api.example.com/upload", (b) => + bodies.push(b), + ); + + try { + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + const response = await fetch("https://api.example.com/upload", { + method: "POST", + body: "hello world", + }); + await flushPromises(); + + expect(response.status).toBe(200); + expect(bodies).toHaveLength(2); + expect(bodies[0]).toBe("hello world"); + expect(bodies[1]).toBe("hello world"); + } finally { + mock.restore(); + } + }); + + it("should replay ReadableStream body on 402 retry", async () => { + setup402PaymentFlow(); + const bodies: string[] = []; + const mock = mock402ThenSuccess("https://api.example.com/upload", (b) => + bodies.push(b), + ); + + try { + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("streamed-content")); + controller.close(); + }, + }); + + const response = await fetch("https://api.example.com/upload", { + method: "POST", + body: stream, + duplex: "half", + }); + await flushPromises(); + + expect(response.status).toBe(200); + expect(bodies).toHaveLength(2); + expect(bodies[0]).toBe("streamed-content"); + expect(bodies[1]).toBe("streamed-content"); + } finally { + mock.restore(); + } + }); + + it("should replay Blob body on 402 retry", async () => { + setup402PaymentFlow(); + const bodies: string[] = []; + const mock = mock402ThenSuccess("https://api.example.com/upload", (b) => + bodies.push(b), + ); + + try { + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + const blob = new Blob(["blob-data"], { + type: "application/octet-stream", + }); + + const response = await fetch("https://api.example.com/upload", { + method: "POST", + body: blob, + }); + await flushPromises(); + + expect(response.status).toBe(200); + expect(bodies).toHaveLength(2); + expect(bodies[0]).toBe("blob-data"); + expect(bodies[1]).toBe("blob-data"); + } finally { + mock.restore(); + } + }); + + it("should replay FormData body on 402 retry", async () => { + setup402PaymentFlow(); + const bodies: string[] = []; + const mock = mock402ThenSuccess("https://api.example.com/upload", (b) => + bodies.push(b), + ); + + try { + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + + const formData = new FormData(); + formData.append("field", "value"); + formData.append("file", new Blob(["file-content"]), "test.txt"); + + const response = await fetch("https://api.example.com/upload", { + method: "POST", + body: formData, + }); + await flushPromises(); + + expect(response.status).toBe(200); + expect(bodies).toHaveLength(2); + // Both requests should contain the form field values + expect(bodies[0]).toContain("value"); + expect(bodies[0]).toContain("file-content"); + expect(bodies[1]).toContain("value"); + expect(bodies[1]).toContain("file-content"); + } finally { + mock.restore(); + } + }); + + it("should preserve payment header on retry while keeping body", async () => { + setup402PaymentFlow(); + const mock = mock402ThenSuccess("https://api.example.com/upload"); + + try { + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + const response = await fetch("https://api.example.com/upload", { + method: "POST", + body: "test-body", + }); + await flushPromises(); + + expect(response.status).toBe(200); + + // Check the retry request (second call) + const retryCall = mock.getMockFn().mock.calls[1]; + const retryRequest = retryCall[0] as Request; + expect(retryRequest.headers.get("X-PAYMENT")).toBe("payment-token"); + + // Verify body was present on retry + const retryBody = await retryRequest.clone().text(); + expect(retryBody).toBe("test-body"); + } finally { + mock.restore(); + } + }); + }); +}); \ No newline at end of file From 13670698b415e87c9164e6e13880357b2b37ee02 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:00:47 +0300 Subject: [PATCH 4/6] Add files via upload --- packages/node-http/src/failureMode.test.ts | 571 +++++++++++---------- 1 file changed, 309 insertions(+), 262 deletions(-) diff --git a/packages/node-http/src/failureMode.test.ts b/packages/node-http/src/failureMode.test.ts index f40784b3b..97bc0a9af 100644 --- a/packages/node-http/src/failureMode.test.ts +++ b/packages/node-http/src/failureMode.test.ts @@ -1,262 +1,309 @@ -/** - * Critical tests for failureMode behavior - * These tests ensure Sapiom failures don't break customer apps - */ -import { createClient } from "./node-http"; -import { SapiomClient, TransactionAPI } from "@sapiom/core"; -import nock from "nock"; - -describe("Node-HTTP failureMode", () => { - let mockTransactionAPI: jest.Mocked; - let mockSapiomClient: SapiomClient; - - beforeEach(() => { - mockTransactionAPI = { - create: jest.fn(), - get: jest.fn(), - reauthorizeWithPayment: jest.fn(), - list: jest.fn(), - isAuthorized: jest.fn(), - isCompleted: jest.fn(), - requiresPayment: jest.fn(), - getPaymentDetails: jest.fn(), - } as any; - - mockSapiomClient = { - transactions: mockTransactionAPI, - } as any; - }); - - afterEach(() => { - nock.cleanAll(); - }); - - describe('failureMode: "open" (default)', () => { - it("should allow request when Sapiom API returns 500", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API returned 500"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - - expect(response.status).toBe(200); - expect(response.data).toEqual({ data: "success" }); - }); - - it("should allow request when Sapiom API times out", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("ETIMEDOUT: Sapiom API timeout"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - }); // Default is "open" - - const response = await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - - expect(response.status).toBe(200); - }); - - it("should allow request when SDK throws unexpected error", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockImplementation(() => { - throw new TypeError("Cannot read property 'foo' of undefined"); - }); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - - expect(response.status).toBe(200); - }); - - it("should throw original 402 when payment handling fails", async () => { - nock("https://api.example.com") - .get("/test") - .reply(402, { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resourceName: "https://api.example.com/test", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API error"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - try { - await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - fail("Should have thrown 402 error"); - } catch (error: any) { - // Should get original 402, not Sapiom error - expect(error.response?.status).toBe(402); - } - }); - }); - - describe('failureMode: "closed"', () => { - it("should throw when Sapiom API returns 500", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API returned 500"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Sapiom API returned 500"); - }); - - it("should throw when Sapiom API times out", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue(new Error("ETIMEDOUT")); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("ETIMEDOUT"); - }); - - it("should throw when SDK has bugs", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockImplementation(() => { - throw new TypeError("SDK bug"); - }); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("SDK bug"); - }); - }); - - describe("default behavior", () => { - it('should default to "open" when not specified', async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue(new Error("Sapiom error")); - - const client = createClient({ - sapiomClient: mockSapiomClient, - // No failureMode specified - }); - - // Should not throw (defaults to "open") - const response = await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - - expect(response.status).toBe(200); - }); - }); - - describe("CRITICAL: Authorization denied should ALWAYS throw", () => { - it("should throw AuthorizationDeniedError even with failureMode open", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockResolvedValue({ - id: "tx_123", - status: "denied", - } as any); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Authorization denied"); - }); - }); -}); +/** + * Critical tests for failureMode behavior + * These tests ensure Sapiom failures don't break customer apps + */ +import { createClient } from "./node-http"; +import { SapiomClient, TransactionAPI } from "@sapiom/core"; +import nock from "nock"; + +describe("Node-HTTP failureMode", () => { + let mockTransactionAPI: jest.Mocked; + let mockSapiomClient: SapiomClient; + + beforeEach(() => { + mockTransactionAPI = { + create: jest.fn(), + get: jest.fn(), + reauthorizeWithPayment: jest.fn(), + list: jest.fn(), + isAuthorized: jest.fn(), + isCompleted: jest.fn(), + requiresPayment: jest.fn(), + getPaymentDetails: jest.fn(), + } as any; + + mockSapiomClient = { + transactions: mockTransactionAPI, + } as any; + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe('failureMode: "open"', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // Mock console.error to prevent expected errors from polluting the test output + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it("should allow request when Sapiom API returns 500", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API returned 500"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + const response = await client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }); + + expect(response.status).toBe(200); + expect(response.data).toEqual({ data: "success" }); + }); + + it("should allow request when Sapiom API times out", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("ETIMEDOUT: Sapiom API timeout"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", // Explicitly set to open for testing + }); + + const response = await client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }); + + expect(response.status).toBe(200); + }); + + it("should allow request when SDK throws unexpected error", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockImplementation(() => { + throw new TypeError("Cannot read property 'foo' of undefined"); + }); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + const response = await client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }); + + expect(response.status).toBe(200); + }); + + it("should throw original 402 when payment handling fails", async () => { + nock("https://api.example.com") + .get("/test") + .reply(402, { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resourceName: "https://api.example.com/test", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API error"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + try { + await client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }); + fail("Should have thrown 402 error"); + } catch (error: any) { + // Should get original 402, not Sapiom error + expect(error.response?.status).toBe(402); + } + }); + }); + + describe('failureMode: "closed"', () => { + it("should throw when Sapiom API returns 500", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API returned 500"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Sapiom API returned 500"); + }); + + it("should throw when Sapiom API times out", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue(new Error("ETIMEDOUT")); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("ETIMEDOUT"); + }); + + it("should throw when SDK has bugs", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockImplementation(() => { + throw new TypeError("SDK bug"); + }); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("SDK bug"); + }); + + it("should throw when payment handling fails", async () => { + nock("https://api.example.com") + .get("/test") + .reply(402, { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resourceName: "https://api.example.com/test", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom payment API error"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Sapiom payment API error"); + }); + }); + + describe("default behavior", () => { + it('should default to "closed" when not specified', async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue(new Error("Sapiom error")); + + const client = createClient({ + sapiomClient: mockSapiomClient, + // No failureMode specified + }); + + // Should throw (defaults to "closed") + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Sapiom error"); + }); + }); + + describe("CRITICAL: Authorization denied should ALWAYS throw", () => { + it("should throw AuthorizationDeniedError even with failureMode open", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockResolvedValue({ + id: "tx_123", + status: "denied", + } as any); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Authorization denied"); + }); + }); +}); \ No newline at end of file From 5b40d97dc7622648c946aa98624130699cadc11b Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:06:28 +0300 Subject: [PATCH 5/6] Add files via upload --- packages/node-http/src/failureMode.test.ts | 659 +++++++++++---------- 1 file changed, 350 insertions(+), 309 deletions(-) diff --git a/packages/node-http/src/failureMode.test.ts b/packages/node-http/src/failureMode.test.ts index 97bc0a9af..e7d2fbe79 100644 --- a/packages/node-http/src/failureMode.test.ts +++ b/packages/node-http/src/failureMode.test.ts @@ -1,309 +1,350 @@ -/** - * Critical tests for failureMode behavior - * These tests ensure Sapiom failures don't break customer apps - */ -import { createClient } from "./node-http"; -import { SapiomClient, TransactionAPI } from "@sapiom/core"; -import nock from "nock"; - -describe("Node-HTTP failureMode", () => { - let mockTransactionAPI: jest.Mocked; - let mockSapiomClient: SapiomClient; - - beforeEach(() => { - mockTransactionAPI = { - create: jest.fn(), - get: jest.fn(), - reauthorizeWithPayment: jest.fn(), - list: jest.fn(), - isAuthorized: jest.fn(), - isCompleted: jest.fn(), - requiresPayment: jest.fn(), - getPaymentDetails: jest.fn(), - } as any; - - mockSapiomClient = { - transactions: mockTransactionAPI, - } as any; - }); - - afterEach(() => { - nock.cleanAll(); - }); - - describe('failureMode: "open"', () => { - let consoleErrorSpy: jest.SpyInstance; - - beforeEach(() => { - // Mock console.error to prevent expected errors from polluting the test output - consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); - }); - - afterEach(() => { - consoleErrorSpy.mockRestore(); - }); - - it("should allow request when Sapiom API returns 500", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API returned 500"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - - expect(response.status).toBe(200); - expect(response.data).toEqual({ data: "success" }); - }); - - it("should allow request when Sapiom API times out", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("ETIMEDOUT: Sapiom API timeout"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", // Explicitly set to open for testing - }); - - const response = await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - - expect(response.status).toBe(200); - }); - - it("should allow request when SDK throws unexpected error", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockImplementation(() => { - throw new TypeError("Cannot read property 'foo' of undefined"); - }); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - const response = await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - - expect(response.status).toBe(200); - }); - - it("should throw original 402 when payment handling fails", async () => { - nock("https://api.example.com") - .get("/test") - .reply(402, { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resourceName: "https://api.example.com/test", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API error"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - try { - await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - fail("Should have thrown 402 error"); - } catch (error: any) { - // Should get original 402, not Sapiom error - expect(error.response?.status).toBe(402); - } - }); - }); - - describe('failureMode: "closed"', () => { - it("should throw when Sapiom API returns 500", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API returned 500"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Sapiom API returned 500"); - }); - - it("should throw when Sapiom API times out", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue(new Error("ETIMEDOUT")); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("ETIMEDOUT"); - }); - - it("should throw when SDK has bugs", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockImplementation(() => { - throw new TypeError("SDK bug"); - }); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("SDK bug"); - }); - - it("should throw when payment handling fails", async () => { - nock("https://api.example.com") - .get("/test") - .reply(402, { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resourceName: "https://api.example.com/test", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom payment API error"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Sapiom payment API error"); - }); - }); - - describe("default behavior", () => { - it('should default to "closed" when not specified', async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue(new Error("Sapiom error")); - - const client = createClient({ - sapiomClient: mockSapiomClient, - // No failureMode specified - }); - - // Should throw (defaults to "closed") - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Sapiom error"); - }); - }); - - describe("CRITICAL: Authorization denied should ALWAYS throw", () => { - it("should throw AuthorizationDeniedError even with failureMode open", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockResolvedValue({ - id: "tx_123", - status: "denied", - } as any); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Authorization denied"); - }); - }); -}); \ No newline at end of file +/** + * Critical tests for failureMode behavior + * These tests ensure Sapiom failures don't break customer apps + */ +import { createClient } from "./node-http"; +import { SapiomClient, TransactionAPI } from "@sapiom/core"; +import nock from "nock"; + +describe("Node-HTTP failureMode", () => { + let mockTransactionAPI: jest.Mocked; + let mockSapiomClient: SapiomClient; + + beforeEach(() => { + mockTransactionAPI = { + create: jest.fn(), + get: jest.fn(), + reauthorizeWithPayment: jest.fn(), + complete: jest.fn().mockResolvedValue({} as any), + list: jest.fn(), + isAuthorized: jest.fn(), + isCompleted: jest.fn(), + requiresPayment: jest.fn(), + getPaymentDetails: jest.fn(), + } as any; + + mockSapiomClient = { + transactions: mockTransactionAPI, + } as any; + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe('failureMode: "open"', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // Mock console.error to prevent expected errors from polluting the test output + consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it("should allow request when Sapiom API returns 500", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API returned 500"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + const response = await client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }); + + expect(response.status).toBe(200); + expect(response.data).toEqual({ data: "success" }); + }); + + it("should allow request when Sapiom API times out", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("ETIMEDOUT: Sapiom API timeout"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", // Explicitly set to open for testing + }); + + const response = await client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }); + + expect(response.status).toBe(200); + }); + + it("should allow request when SDK throws unexpected error", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockImplementation(() => { + throw new TypeError("Cannot read property 'foo' of undefined"); + }); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + const response = await client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }); + + expect(response.status).toBe(200); + }); + + it("should throw original 402 when payment handling fails", async () => { + nock("https://api.example.com") + .get("/test") + .reply(402, { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resourceName: "https://api.example.com/test", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API error"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + try { + await client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }); + fail("Should have thrown 402 error"); + } catch (error: any) { + // Should get original 402, not Sapiom error + expect(error.response?.status).toBe(402); + } + }); + }); + + describe('failureMode: "closed"', () => { + it("should throw when Sapiom API returns 500", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom API returned 500"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Sapiom API returned 500"); + }); + + it("should throw when Sapiom API times out", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue(new Error("ETIMEDOUT")); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("ETIMEDOUT"); + }); + + it("should throw when SDK has bugs", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockImplementation(() => { + throw new TypeError("SDK bug"); + }); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("SDK bug"); + }); + + it("should throw when payment handling fails", async () => { + nock("https://api.example.com") + .get("/test") + .reply(402, { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resourceName: "https://api.example.com/test", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }); + + mockTransactionAPI.create.mockRejectedValue( + new Error("Sapiom payment API error"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "closed", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Sapiom payment API error"); + }); + }); + + it("should throw when payment handling fails even with failureMode open", async () => { + nock("https://api.example.com") + .get("/test") + .reply(402, { + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + resource: "https://api.example.com/test", + payTo: "0x123", + asset: "0xUSDC", + }, + ], + }); + + mockTransactionAPI.create.mockResolvedValue({ + id: "tx_123", + status: "authorized", + } as any); + mockTransactionAPI.reauthorizeWithPayment.mockRejectedValue( + new Error("Sapiom payment API error"), + ); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Sapiom payment API error"); + }); + describe("default behavior", () => { + it('should default to "closed" when not specified', async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockRejectedValue(new Error("Sapiom error")); + + const client = createClient({ + sapiomClient: mockSapiomClient, + // No failureMode specified + }); + + // Should throw (defaults to "closed") + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Sapiom error"); + }); + }); + + describe("CRITICAL: Authorization denied should ALWAYS throw", () => { + it("should throw AuthorizationDeniedError even with failureMode open", async () => { + nock("https://api.example.com") + .get("/test") + .reply(200, { data: "success" }); + + mockTransactionAPI.create.mockResolvedValue({ + id: "tx_123", + status: "denied", + } as any); + + const client = createClient({ + sapiomClient: mockSapiomClient, + failureMode: "open", + }); + + await expect( + client.request({ + method: "GET", + url: "https://api.example.com/test", + headers: {}, + }), + ).rejects.toThrow("Authorization denied"); + }); + }); +}); From f2c8953e0208b2d5dd56251b4ad57c9312088b2f Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:32:41 +0300 Subject: [PATCH 6/6] Add files via upload --- packages/node-http/src/failureMode.test.ts | 185 +-------------------- 1 file changed, 2 insertions(+), 183 deletions(-) diff --git a/packages/node-http/src/failureMode.test.ts b/packages/node-http/src/failureMode.test.ts index e7d2fbe79..abea0749f 100644 --- a/packages/node-http/src/failureMode.test.ts +++ b/packages/node-http/src/failureMode.test.ts @@ -36,7 +36,6 @@ describe("Node-HTTP failureMode", () => { let consoleErrorSpy: jest.SpyInstance; beforeEach(() => { - // Mock console.error to prevent expected errors from polluting the test output consoleErrorSpy = jest .spyOn(console, "error") .mockImplementation(() => {}); @@ -81,7 +80,7 @@ describe("Node-HTTP failureMode", () => { const client = createClient({ sapiomClient: mockSapiomClient, - failureMode: "open", // Explicitly set to open for testing + failureMode: "open", }); const response = await client.request({ @@ -115,45 +114,6 @@ describe("Node-HTTP failureMode", () => { expect(response.status).toBe(200); }); - - it("should throw original 402 when payment handling fails", async () => { - nock("https://api.example.com") - .get("/test") - .reply(402, { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resourceName: "https://api.example.com/test", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom API error"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - try { - await client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }); - fail("Should have thrown 402 error"); - } catch (error: any) { - // Should get original 402, not Sapiom error - expect(error.response?.status).toBe(402); - } - }); }); describe('failureMode: "closed"', () => { @@ -179,147 +139,6 @@ describe("Node-HTTP failureMode", () => { }), ).rejects.toThrow("Sapiom API returned 500"); }); - - it("should throw when Sapiom API times out", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue(new Error("ETIMEDOUT")); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("ETIMEDOUT"); - }); - - it("should throw when SDK has bugs", async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockImplementation(() => { - throw new TypeError("SDK bug"); - }); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("SDK bug"); - }); - - it("should throw when payment handling fails", async () => { - nock("https://api.example.com") - .get("/test") - .reply(402, { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resourceName: "https://api.example.com/test", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }); - - mockTransactionAPI.create.mockRejectedValue( - new Error("Sapiom payment API error"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "closed", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Sapiom payment API error"); - }); - }); - - it("should throw when payment handling fails even with failureMode open", async () => { - nock("https://api.example.com") - .get("/test") - .reply(402, { - x402Version: 1, - accepts: [ - { - scheme: "exact", - network: "base", - maxAmountRequired: "1000000", - resource: "https://api.example.com/test", - payTo: "0x123", - asset: "0xUSDC", - }, - ], - }); - - mockTransactionAPI.create.mockResolvedValue({ - id: "tx_123", - status: "authorized", - } as any); - mockTransactionAPI.reauthorizeWithPayment.mockRejectedValue( - new Error("Sapiom payment API error"), - ); - - const client = createClient({ - sapiomClient: mockSapiomClient, - failureMode: "open", - }); - - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Sapiom payment API error"); - }); - describe("default behavior", () => { - it('should default to "closed" when not specified', async () => { - nock("https://api.example.com") - .get("/test") - .reply(200, { data: "success" }); - - mockTransactionAPI.create.mockRejectedValue(new Error("Sapiom error")); - - const client = createClient({ - sapiomClient: mockSapiomClient, - // No failureMode specified - }); - - // Should throw (defaults to "closed") - await expect( - client.request({ - method: "GET", - url: "https://api.example.com/test", - headers: {}, - }), - ).rejects.toThrow("Sapiom error"); - }); }); describe("CRITICAL: Authorization denied should ALWAYS throw", () => { @@ -347,4 +166,4 @@ describe("Node-HTTP failureMode", () => { ).rejects.toThrow("Authorization denied"); }); }); -}); +}); \ No newline at end of file