From c66212abb4bd5ef611bebfc4a007b9fd754d8538 Mon Sep 17 00:00:00 2001 From: dev-fani Date: Sat, 17 Oct 2026 18:59:14 +0000 Subject: [PATCH] feat: implement webhooks, analytics, state subscriptions, and parseAmount tests - #1037: Comprehensive unit tests and edge cases for parseAmount (valid inputs, boundaries, negative numbers, truncation/rounding, invalid formats) - #1036: Add optional analytics tracking API with PrivacyController (secret scrubbing, address masking, PII protection), AnalyticsCollector, and reporting - #1035: Implement WebhookClient with HMAC-SHA256 verification, replay protection tolerance, middleware, event handlers, and HTTP adapter - #1034: Add WebSocket-based real-time invoice state subscription client with filtering and automatic exponential-backoff reconnection Closes #1034 Closes #1035 Closes #1036 Closes #1037 --- src/analyticsTracking.ts | 548 ++++++++++++++++++++++++++ src/index.ts | 49 ++- src/invoiceStateSubscription.ts | 522 ++++++++++++++++++++++++ src/utils.ts | 19 +- src/webhookClient.ts | 446 +++++++++++++++++++++ src/webhooks/index.ts | 17 + test/analyticsTracking.test.ts | 233 +++++++++++ test/invoiceStateSubscription.test.ts | 275 +++++++++++++ test/parseAmount.test.ts | 162 ++++++++ test/sdkExports.test.ts | 13 + test/webhookClient.test.ts | 293 ++++++++++++++ 11 files changed, 2574 insertions(+), 3 deletions(-) create mode 100644 src/analyticsTracking.ts create mode 100644 src/invoiceStateSubscription.ts create mode 100644 src/webhookClient.ts create mode 100644 src/webhooks/index.ts create mode 100644 test/analyticsTracking.test.ts create mode 100644 test/invoiceStateSubscription.test.ts create mode 100644 test/parseAmount.test.ts create mode 100644 test/webhookClient.test.ts diff --git a/src/analyticsTracking.ts b/src/analyticsTracking.ts new file mode 100644 index 0000000..1e8c830 --- /dev/null +++ b/src/analyticsTracking.ts @@ -0,0 +1,548 @@ +/** + * Optional analytics tracking for SDK usage metrics with privacy controls and reporting. + * + * Designed to provide privacy-preserving usage metrics for SDK consumers. + * Strictly opt-in (enabled: false by default). + */ + +import { createHash } from "crypto"; + +export type PrivacyLevel = "strict" | "anonymized" | "standard"; + +export interface AnalyticsPrivacyConfig { + /** + * Overall privacy level: + * - "strict": completely drops all addresses, metadata, and custom properties. + * - "anonymized": hashes/masks Stellar public keys and scrubs sensitive data (default). + * - "standard": allows public keys but scrubs secret keys and credentials. + */ + level?: PrivacyLevel; + /** Whether to anonymize Stellar public keys (G...). Default: true. */ + anonymizeAddresses?: boolean; + /** Whether to strip secret keys (S...) and credentials. Default: true. */ + stripSecrets?: boolean; + /** Whether to strip PII (email, IP, names). Default: true. */ + allowPII?: boolean; + /** Respect browser / environment Do Not Track headers or flags. Default: true. */ + respectDoNotTrack?: boolean; +} + +export interface AnalyticsEventInput { + name: string; + category?: string; + properties?: Record; + timestamp?: number; +} + +export interface AnalyticsEvent { + name: string; + category: string; + properties: Record; + timestamp: number; +} + +export interface MethodMetric { + calls: number; + errors: number; + errorRate: number; + avgDurationMs: number; + minDurationMs: number; + maxDurationMs: number; + p50DurationMs: number; + p95DurationMs: number; + p99DurationMs: number; +} + +export interface AnalyticsReport { + generatedAt: number; + periodMs: number; + privacyLevel: PrivacyLevel; + totalEvents: number; + totalMethodCalls: number; + totalErrors: number; + overallErrorRate: number; + methods: Record; + eventsByCategory: Record; + metrics: Record; + events: AnalyticsEvent[]; +} + +export interface AnalyticsTrackerOptions { + /** Strictly opt-in: defaults to false. */ + enabled?: boolean; + /** Privacy controls configuration. */ + privacy?: AnalyticsPrivacyConfig; + /** Remote reporting endpoint URL for flush(). */ + endpoint?: string; + /** Auto-flush interval in milliseconds (0 or undefined to disable auto-flush). */ + flushIntervalMs?: number; + /** Maximum number of raw events retained in the collector buffer. Default: 1000. */ + maxQueueSize?: number; + /** Custom reporter callback invoked on flush or report generation. */ + onReport?: (report: AnalyticsReport) => void; + /** Optional custom fetch implementation for network reporting. */ + fetchFn?: typeof fetch; +} + +// --------------------------------------------------------------------------- +// Privacy Controller +// --------------------------------------------------------------------------- + +const STELLAR_SECRET_PATTERN = /\bS[A-Z0-9]{55}\b/g; +const STELLAR_PUBLIC_PATTERN = /\bG[A-Z0-9]{55}\b/g; +const PII_KEYS = new Set([ + "ip", + "ipaddress", + "ip_address", + "email", + "emailaddress", + "email_address", + "phone", + "phonenumber", + "ssn", + "password", + "secret", + "privatekey", + "private_key", + "authorization", + "token", +]); + +export class PrivacyController { + private readonly config: Required; + + constructor(config?: AnalyticsPrivacyConfig) { + this.config = { + level: config?.level ?? "anonymized", + anonymizeAddresses: config?.anonymizeAddresses ?? true, + stripSecrets: config?.stripSecrets ?? true, + allowPII: config?.allowPII ?? false, + respectDoNotTrack: config?.respectDoNotTrack ?? true, + }; + } + + get level(): PrivacyLevel { + return this.config.level; + } + + isDoNotTrackEnabled(): boolean { + if (!this.config.respectDoNotTrack) return false; + if (typeof navigator !== "undefined" && "doNotTrack" in navigator) { + return (navigator as { doNotTrack?: string }).doNotTrack === "1"; + } + if (typeof process !== "undefined" && process.env?.DO_NOT_TRACK === "1") { + return true; + } + return false; + } + + anonymizeAddress(address: string): string { + if (this.config.level === "strict") { + return "[REDACTED_ADDRESS]"; + } + if (!this.config.anonymizeAddresses && this.config.level === "standard") { + return address; + } + // Deterministic pseudonym: first 4, last 4 chars + 8-char sha256 hash + const hash = createHash("sha256").update(address).digest("hex").slice(0, 8); + return `${address.slice(0, 4)}...${hash}...${address.slice(-4)}`; + } + + sanitizeString(val: string): string { + let out = val; + if (this.config.stripSecrets) { + out = out.replace(STELLAR_SECRET_PATTERN, "[REDACTED_SECRET]"); + } + if (this.config.anonymizeAddresses || this.config.level !== "standard") { + out = out.replace(STELLAR_PUBLIC_PATTERN, (match) => this.anonymizeAddress(match)); + } + return out; + } + + sanitizeValue(key: string, value: unknown): unknown { + if (value === null || value === undefined) return value; + + const lowerKey = key.toLowerCase(); + if (!this.config.allowPII && PII_KEYS.has(lowerKey)) { + return "[REDACTED_PII]"; + } + + if (typeof value === "string") { + return this.sanitizeString(value); + } + + if (typeof value === "bigint" || typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (Array.isArray(value)) { + if (this.config.level === "strict") return []; + return value.map((item, idx) => this.sanitizeValue(`${key}_${idx}`, item)); + } + + if (typeof value === "object") { + if (this.config.level === "strict") return {}; + return this.sanitizeProperties(value as Record); + } + + return String(value); + } + + sanitizeProperties(properties?: Record): Record { + if (!properties) return {}; + if (this.config.level === "strict") return {}; + + const sanitized: Record = {}; + for (const [key, val] of Object.entries(properties)) { + sanitized[key] = this.sanitizeValue(key, val); + } + return sanitized; + } +} + +// --------------------------------------------------------------------------- +// Analytics Collector +// --------------------------------------------------------------------------- + +interface MethodRecord { + count: number; + errors: number; + durations: number[]; +} + +export class AnalyticsCollector { + private readonly maxQueueSize: number; + private readonly events: AnalyticsEvent[] = []; + private readonly methods = new Map(); + private readonly metrics = new Map(); + private readonly categoryCounts = new Map(); + private startTime: number = Date.now(); + + constructor(maxQueueSize = 1000) { + this.maxQueueSize = maxQueueSize; + } + + recordEvent(event: AnalyticsEvent): void { + if (this.events.length >= this.maxQueueSize) { + this.events.shift(); // Evict oldest event when queue is full + } + this.events.push(event); + + const cat = event.category || "general"; + this.categoryCounts.set(cat, (this.categoryCounts.get(cat) ?? 0) + 1); + } + + recordMethod(method: string, durationMs: number, success: boolean): void { + let rec = this.methods.get(method); + if (!rec) { + rec = { count: 0, errors: 0, durations: [] }; + this.methods.set(method, rec); + } + rec.count += 1; + if (!success) { + rec.errors += 1; + } + rec.durations.push(durationMs); + // Keep max 500 duration samples per method to avoid unbounded memory + if (rec.durations.length > 500) { + rec.durations.shift(); + } + } + + recordMetric(name: string, value: number): void { + this.metrics.set(name, (this.metrics.get(name) ?? 0) + value); + } + + getEvents(): AnalyticsEvent[] { + return [...this.events]; + } + + getMetrics(): Record { + return Object.fromEntries(this.metrics.entries()); + } + + private calculatePercentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(index, sorted.length - 1))] ?? 0; + } + + generateReport(privacyLevel: PrivacyLevel = "anonymized"): AnalyticsReport { + const now = Date.now(); + const methodsReport: Record = {}; + + let totalMethodCalls = 0; + let totalErrors = 0; + + for (const [method, rec] of this.methods.entries()) { + const sorted = [...rec.durations].sort((a, b) => a - b); + const sum = sorted.reduce((acc, d) => acc + d, 0); + const avg = sorted.length > 0 ? Number((sum / sorted.length).toFixed(2)) : 0; + const min = sorted.length > 0 ? (sorted[0] ?? 0) : 0; + const max = sorted.length > 0 ? (sorted[sorted.length - 1] ?? 0) : 0; + const errorRate = rec.count > 0 ? Number((rec.errors / rec.count).toFixed(4)) : 0; + + totalMethodCalls += rec.count; + totalErrors += rec.errors; + + methodsReport[method] = { + calls: rec.count, + errors: rec.errors, + errorRate, + avgDurationMs: avg, + minDurationMs: min, + maxDurationMs: max, + p50DurationMs: this.calculatePercentile(sorted, 50), + p95DurationMs: this.calculatePercentile(sorted, 95), + p99DurationMs: this.calculatePercentile(sorted, 99), + }; + } + + const overallErrorRate = + totalMethodCalls > 0 ? Number((totalErrors / totalMethodCalls).toFixed(4)) : 0; + + return { + generatedAt: now, + periodMs: now - this.startTime, + privacyLevel, + totalEvents: this.events.length, + totalMethodCalls, + totalErrors, + overallErrorRate, + methods: methodsReport, + eventsByCategory: Object.fromEntries(this.categoryCounts.entries()), + metrics: Object.fromEntries(this.metrics.entries()), + events: [...this.events], + }; + } + + reset(): void { + this.events.length = 0; + this.methods.clear(); + this.metrics.clear(); + this.categoryCounts.clear(); + this.startTime = Date.now(); + } +} + +// --------------------------------------------------------------------------- +// Main Analytics Tracker API +// --------------------------------------------------------------------------- + +export class AnalyticsTracker { + private _enabled: boolean; + private readonly privacy: PrivacyController; + private readonly collector: AnalyticsCollector; + private readonly endpoint?: string; + private readonly onReport?: (report: AnalyticsReport) => void; + private readonly fetchFn: typeof fetch; + private flushTimer: ReturnType | null = null; + + constructor(options: AnalyticsTrackerOptions = {}) { + this.privacy = new PrivacyController(options.privacy); + // Respect Do Not Track + if (this.privacy.isDoNotTrackEnabled()) { + this._enabled = false; + } else { + this._enabled = options.enabled ?? false; + } + + this.collector = new AnalyticsCollector(options.maxQueueSize ?? 1000); + this.endpoint = options.endpoint; + this.onReport = options.onReport; + this.fetchFn = options.fetchFn ?? (typeof fetch !== "undefined" ? fetch : (() => Promise.resolve({ ok: true })) as unknown as typeof fetch); + + if (this._enabled && options.flushIntervalMs && options.flushIntervalMs > 0) { + this.flushTimer = setInterval(() => void this.flush(), options.flushIntervalMs); + if (typeof this.flushTimer === "object" && this.flushTimer !== null && "unref" in this.flushTimer) { + (this.flushTimer as NodeJS.Timeout).unref(); + } + } + } + + isEnabled(): boolean { + return this._enabled && !this.privacy.isDoNotTrackEnabled(); + } + + optIn(): void { + if (!this.privacy.isDoNotTrackEnabled()) { + this._enabled = true; + } + } + + optOut(): void { + this._enabled = false; + this.collector.reset(); + } + + getPrivacyLevel(): PrivacyLevel { + return this.privacy.level; + } + + /** + * Track an arbitrary SDK event with privacy scrubbing applied. + */ + track(event: AnalyticsEventInput): void { + if (!this.isEnabled()) return; + + const sanitizedProps = this.privacy.sanitizeProperties(event.properties); + const sanitizedEvent: AnalyticsEvent = { + name: this.privacy.sanitizeString(event.name), + category: event.category || "general", + properties: sanitizedProps, + timestamp: event.timestamp ?? Date.now(), + }; + + this.collector.recordEvent(sanitizedEvent); + } + + /** + * Track method execution latency, success/failure status, and metadata. + */ + trackMethod( + method: string, + durationMs: number, + success: boolean, + metadata?: Record, + ): void { + if (!this.isEnabled()) return; + + this.collector.recordMethod(method, Math.max(0, durationMs), success); + this.track({ + name: `method:${method}`, + category: "method_call", + properties: { + method, + durationMs, + success, + ...metadata, + }, + }); + } + + /** + * Track an error encountered during SDK operation. + */ + trackError(error: Error | string, context?: Record): void { + if (!this.isEnabled()) return; + + const message = typeof error === "string" ? error : error.message; + const name = typeof error === "string" ? "Error" : error.name; + + this.track({ + name: "error_occurred", + category: "errors", + properties: { + errorName: name, + errorMessage: message, + ...context, + }, + }); + this.collector.recordMetric("error_count", 1); + } + + /** + * Track a numerical metric counter or gauge. + */ + trackMetric(name: string, value: number): void { + if (!this.isEnabled()) return; + this.collector.recordMetric(name, value); + } + + /** + * Generate a comprehensive analytics report of all metrics accumulated. + */ + generateReport(): AnalyticsReport { + const report = this.collector.generateReport(this.privacy.level); + if (this.onReport) { + try { + this.onReport(report); + } catch { + // Non-blocking report callback + } + } + return report; + } + + /** + * Dispatch report to configured remote endpoint, notify onReport, and reset counters. + */ + async flush(): Promise { + if (!this.isEnabled()) return null; + + const report = this.generateReport(); + this.collector.reset(); + + if (this.endpoint) { + try { + await this.fetchFn(this.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(report), + }); + } catch { + // Analytics flush failure must never break consumer operations + } + } + + return report; + } + + /** + * Cleanup timer resources and reset collector. + */ + destroy(): void { + if (this.flushTimer !== null) { + clearInterval(this.flushTimer); + this.flushTimer = null; + } + this.collector.reset(); + } +} + +/** + * Convenience helper to wrap any client or service with automated analytics tracking. + */ +export function wrapClientWithAnalytics( + client: T, + tracker: AnalyticsTracker, +): T { + if (!tracker.isEnabled()) { + return client; + } + + return new Proxy(client, { + get(target, prop, receiver) { + const orig = Reflect.get(target, prop, receiver); + if (typeof orig === "function" && typeof prop === "string") { + return function (this: unknown, ...args: unknown[]) { + const start = Date.now(); + try { + const result = (orig as (...a: unknown[]) => unknown).apply(this ?? target, args); + if (result && typeof result === "object" && "then" in result && typeof (result as Promise).then === "function") { + return (result as Promise) + .then((res) => { + tracker.trackMethod(prop, Date.now() - start, true); + return res; + }) + .catch((err) => { + tracker.trackMethod(prop, Date.now() - start, false, { + errorMessage: (err as Error)?.message, + }); + throw err; + }); + } + tracker.trackMethod(prop, Date.now() - start, true); + return result; + } catch (err) { + tracker.trackMethod(prop, Date.now() - start, false, { + errorMessage: (err as Error)?.message, + }); + throw err; + } + }; + } + return orig; + }, + }); +} diff --git a/src/index.ts b/src/index.ts index bf64e06..1f0c23b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -404,7 +404,7 @@ export { watchContractUpgrade } from "./upgrade.js"; export { calculateFee } from "./fee.js"; -export { formatAddress } from "./utils.js"; +export { formatAddress, parseAmount, formatAmount } from "./utils.js"; export { resolveToken } from "./token.js"; @@ -1714,3 +1714,50 @@ export type { RetirementManagerOptions, RetirementEventMap, } from "./invoiceRetirement.js"; + +// Real-time invoice state subscription (#1034) +export { + InvoiceStateSubscriber, + matchesFilter, +} from "./invoiceStateSubscription.js"; +export type { + InvoiceStateUpdate, + InvoiceStateFilter, + InvoiceStateSubscriberConfig, + InvoiceStateSubscriptionHandle, + ReconnectConfig, +} from "./invoiceStateSubscription.js"; + +// Webhook client library (#1035) +export { + WebhookClient, + WebhookError, + WebhookVerificationError, + WebhookTimestampError, + WebhookPayloadMalformedError, +} from "./webhookClient.js"; +export type { + WebhookClientConfig, + WebhookEventPayload, + WebhookProcessResult, + WebhookHandler, + WebhookErrorHandler, + WebhookClientMiddleware, +} from "./webhookClient.js"; + +// Analytics tracking & privacy controls (#1036) +export { + AnalyticsTracker, + PrivacyController, + AnalyticsCollector, + wrapClientWithAnalytics, +} from "./analyticsTracking.js"; +export type { + PrivacyLevel, + AnalyticsPrivacyConfig, + AnalyticsEventInput, + AnalyticsEvent, + MethodMetric, + AnalyticsReport, + AnalyticsTrackerOptions, +} from "./analyticsTracking.js"; diff --git a/src/invoiceStateSubscription.ts b/src/invoiceStateSubscription.ts new file mode 100644 index 0000000..c3ec789 --- /dev/null +++ b/src/invoiceStateSubscription.ts @@ -0,0 +1,522 @@ +/** + * WebSocket-based real-time invoice state subscription client with filtering and automatic reconnection. + */ + +import type { InvoiceStatus } from "./types.js"; + +export interface InvoiceStateUpdate { + invoiceId: string; + status: InvoiceStatus; + previousStatus?: InvoiceStatus; + funded: bigint; + targetAmount?: bigint; + recipients?: Array<{ address: string; amount: bigint }>; + lastPayment?: { + payer: string; + amount: bigint; + timestamp: number; + txHash?: string; + }; + timestamp: number; + ledger?: number; + metadata?: Record; +} + +export interface InvoiceStateFilter { + /** Target invoice ID(s) or "*" for all. */ + invoiceId?: string | string[]; + /** Filter by lifecycle status. */ + status?: InvoiceStatus | InvoiceStatus[]; + /** Filter by invoice creator address. */ + creator?: string; + /** Filter by latest payment payer address. */ + payer?: string; + /** Filter by minimum funded amount in stroops. */ + minFunded?: bigint; + /** Custom predicate filter. */ + custom?: (event: InvoiceStateUpdate) => boolean; +} + +export interface ReconnectConfig { + enabled?: boolean; + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + jitter?: boolean; +} + +export interface InvoiceStateSubscriberConfig { + wsUrl?: string; + rpcUrl?: string; + wsFactory?: () => WebSocket; + reconnect?: ReconnectConfig; + heartbeatIntervalMs?: number; +} + +export interface InvoiceStateSubscriptionHandle { + readonly id: string; + readonly filter: InvoiceStateFilter; + readonly isActive: boolean; + unsubscribe(): void; + updateFilter(newFilter: Partial): void; +} + +type SubscriberCallback = (event: InvoiceStateUpdate) => void; + +interface ActiveSubscription { + id: string; + filter: InvoiceStateFilter; + callback: SubscriberCallback; + active: boolean; +} + +function rpcToWsUrl(url: string): string { + return url.replace(/^http:/i, "ws:").replace(/^https:/i, "wss:"); +} + +/** + * Evaluates whether an InvoiceStateUpdate matches the given InvoiceStateFilter. + */ +export function matchesFilter( + event: InvoiceStateUpdate, + filter?: InvoiceStateFilter, +): boolean { + if (!filter) return true; + + // Filter by invoiceId + if (filter.invoiceId !== undefined && filter.invoiceId !== "*") { + if (Array.isArray(filter.invoiceId)) { + if (!filter.invoiceId.includes(event.invoiceId)) return false; + } else if (filter.invoiceId !== event.invoiceId) { + return false; + } + } + + // Filter by status + if (filter.status !== undefined) { + if (Array.isArray(filter.status)) { + if (!filter.status.includes(event.status)) return false; + } else if (filter.status !== event.status) { + return false; + } + } + + // Filter by creator + if (filter.creator !== undefined) { + const eventCreator = event.metadata?.creator as string | undefined; + if (!eventCreator || eventCreator.toLowerCase() !== filter.creator.toLowerCase()) { + return false; + } + } + + // Filter by payer + if (filter.payer !== undefined) { + const payer = event.lastPayment?.payer; + if (!payer || payer.toLowerCase() !== filter.payer.toLowerCase()) { + return false; + } + } + + // Filter by minFunded + if (filter.minFunded !== undefined) { + if (event.funded < filter.minFunded) { + return false; + } + } + + // Filter by custom predicate + if (filter.custom) { + try { + if (!filter.custom(event)) return false; + } catch { + return false; + } + } + + return true; +} + +export class InvoiceStateSubscriber { + private ws: WebSocket | null = null; + private readonly wsUrl: string; + private readonly wsFactory: () => WebSocket; + private readonly reconnectConfig: Required; + private readonly heartbeatIntervalMs: number; + + private connected = false; + private stopped = false; + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; + + private readonly subscriptions = new Map(); + private nextSubId = 1; + + // Lifecycle listeners + private readonly onConnectListeners = new Set<() => void>(); + private readonly onDisconnectListeners = new Set<(reason?: string) => void>(); + private readonly onReconnectListeners = new Set<(attempt: number, max: number) => void>(); + private readonly onReconnectFailedListeners = new Set<(attempts: number) => void>(); + private readonly onErrorListeners = new Set<(err: Error) => void>(); + + constructor(config: InvoiceStateSubscriberConfig) { + const url = config.wsUrl ?? (config.rpcUrl ? rpcToWsUrl(config.rpcUrl) : "ws://localhost:8000"); + this.wsUrl = url; + + this.wsFactory = + config.wsFactory ?? + (() => { + if (typeof WebSocket !== "undefined") { + return new WebSocket(this.wsUrl); + } + throw new Error( + "WebSocket is not available in this environment. Provide a custom wsFactory.", + ); + }); + + this.reconnectConfig = { + enabled: config.reconnect?.enabled ?? true, + maxAttempts: config.reconnect?.maxAttempts ?? 5, + baseDelayMs: config.reconnect?.baseDelayMs ?? 1000, + maxDelayMs: config.reconnect?.maxDelayMs ?? 30000, + jitter: config.reconnect?.jitter ?? true, + }; + + this.heartbeatIntervalMs = config.heartbeatIntervalMs ?? 30000; + } + + get isConnected(): boolean { + return this.connected; + } + + get activeSubscriptionCount(): number { + return this.subscriptions.size; + } + + /** + * Subscribe to real-time invoice state updates with optional filter criteria. + */ + subscribe( + filter: InvoiceStateFilter | string, + callback: SubscriberCallback, + ): InvoiceStateSubscriptionHandle { + const normalizedFilter: InvoiceStateFilter = + typeof filter === "string" ? { invoiceId: filter } : { ...filter }; + + const subId = `sub_${this.nextSubId++}`; + const subRecord: ActiveSubscription = { + id: subId, + filter: normalizedFilter, + callback, + active: true, + }; + + this.subscriptions.set(subId, subRecord); + + if (this.connected && this.ws?.readyState === 1) { + this.sendSubscriptionFilter(subRecord); + } else if (!this.connected && this.ws === null && !this.stopped) { + this.connect(); + } + + const handle: InvoiceStateSubscriptionHandle = { + id: subId, + get filter() { + return { ...subRecord.filter }; + }, + get isActive() { + return subRecord.active; + }, + unsubscribe: () => { + if (!subRecord.active) return; + subRecord.active = false; + this.subscriptions.delete(subId); + if (this.connected && this.ws?.readyState === 1) { + try { + this.ws.send(JSON.stringify({ action: "unsubscribe", subscriptionId: subId })); + } catch { + // Ignore send failure on unsubscribe + } + } + if (this.subscriptions.size === 0) { + this.disconnect(); + } + }, + updateFilter: (newFilter: Partial) => { + if (!subRecord.active) return; + subRecord.filter = { ...subRecord.filter, ...newFilter }; + if (this.connected && this.ws?.readyState === 1) { + this.sendSubscriptionFilter(subRecord); + } + }, + }; + + return handle; + } + + // ------------------------------------------------------------------------- + // WebSocket Lifecycle & Connection Management + // ------------------------------------------------------------------------- + + connect(): void { + if (this.stopped) return; + if (this.connected || (this.ws && this.ws.readyState === 0)) return; + + try { + this.ws = this.wsFactory(); + } catch (err) { + this.handleSocketError(err instanceof Error ? err : new Error(String(err))); + this.scheduleReconnect(); + return; + } + + this.ws.onopen = () => { + this.connected = true; + this.reconnectAttempts = 0; + this.startHeartbeat(); + + // Resubscribe all active subscriptions + for (const sub of this.subscriptions.values()) { + if (sub.active) { + this.sendSubscriptionFilter(sub); + } + } + + for (const cb of this.onConnectListeners) { + try { cb(); } catch {} + } + }; + + this.ws.onclose = (event) => { + this.cleanupSocket(); + for (const cb of this.onDisconnectListeners) { + try { cb(event.reason); } catch {} + } + this.scheduleReconnect(); + }; + + this.ws.onerror = () => { + this.handleSocketError(new Error("WebSocket connection error")); + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event.data); + }; + } + + private sendSubscriptionFilter(sub: ActiveSubscription): void { + if (!this.ws || this.ws.readyState !== 1) return; + try { + this.ws.send( + JSON.stringify({ + action: "subscribe", + subscriptionId: sub.id, + filter: { + ...sub.filter, + minFunded: sub.filter.minFunded?.toString(), + custom: undefined, // Predicate runs client-side + }, + }), + ); + } catch { + // Non-blocking socket write + } + } + + private handleMessage(data: unknown): void { + if (typeof data !== "string") return; + + try { + const raw = JSON.parse(data) as Record; + // Skip server ack messages or heartbeat pongs + if (raw.type === "pong" || raw.action === "ack") return; + + const update = this.normalizeInvoiceStateUpdate(raw); + if (!update) return; + + // Dispatch to all matching subscriptions + for (const sub of this.subscriptions.values()) { + if (sub.active && matchesFilter(update, sub.filter)) { + try { + sub.callback(update); + } catch { + // Consumer handler error should not break event loop + } + } + } + } catch { + // Malformed json message + } + } + + private normalizeInvoiceStateUpdate(raw: Record): InvoiceStateUpdate | null { + const invoiceId = (raw.invoiceId as string) || (raw.id as string); + const status = (raw.status as InvoiceStatus) || "Pending"; + if (!invoiceId) return null; + + let funded = 0n; + if (typeof raw.funded === "bigint") { + funded = raw.funded; + } else if (typeof raw.funded === "number") { + funded = BigInt(Math.floor(raw.funded)); + } else if (typeof raw.funded === "string") { + try { funded = BigInt(raw.funded); } catch { funded = 0n; } + } + + let targetAmount: bigint | undefined; + if (typeof raw.targetAmount === "bigint") { + targetAmount = raw.targetAmount; + } else if (typeof raw.targetAmount === "string") { + try { targetAmount = BigInt(raw.targetAmount); } catch { targetAmount = undefined; } + } + + return { + invoiceId, + status, + previousStatus: (raw.previousStatus as InvoiceStatus) || undefined, + funded, + targetAmount, + recipients: Array.isArray(raw.recipients) + ? raw.recipients.map((r: { address: string; amount: unknown }) => ({ + address: r.address, + amount: typeof r.amount === "bigint" ? r.amount : BigInt(String(r.amount || 0)), + })) + : undefined, + lastPayment: raw.lastPayment + ? { + payer: (raw.lastPayment as { payer: string }).payer, + amount: BigInt(String((raw.lastPayment as { amount: unknown }).amount || 0)), + timestamp: Number((raw.lastPayment as { timestamp: unknown }).timestamp || Date.now()), + txHash: (raw.lastPayment as { txHash?: string }).txHash, + } + : undefined, + timestamp: Number(raw.timestamp || Date.now()), + ledger: typeof raw.ledger === "number" ? raw.ledger : undefined, + metadata: typeof raw.metadata === "object" && raw.metadata !== null ? (raw.metadata as Record) : undefined, + }; + } + + private scheduleReconnect(): void { + if (this.stopped || !this.reconnectConfig.enabled) return; + + this.reconnectAttempts++; + if (this.reconnectAttempts > this.reconnectConfig.maxAttempts) { + for (const cb of this.onReconnectFailedListeners) { + try { cb(this.reconnectAttempts - 1); } catch {} + } + return; + } + + let delay = Math.min( + this.reconnectConfig.baseDelayMs * Math.pow(2, this.reconnectAttempts - 1), + this.reconnectConfig.maxDelayMs, + ); + + if (this.reconnectConfig.jitter) { + delay = Math.floor(delay * (0.8 + Math.random() * 0.4)); + } + + for (const cb of this.onReconnectListeners) { + try { cb(this.reconnectAttempts, this.reconnectConfig.maxAttempts); } catch {} + } + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + + if (typeof this.reconnectTimer === "object" && this.reconnectTimer !== null && "unref" in this.reconnectTimer) { + (this.reconnectTimer as NodeJS.Timeout).unref(); + } + } + + private handleSocketError(err: Error): void { + for (const cb of this.onErrorListeners) { + try { cb(err); } catch {} + } + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + if (this.heartbeatIntervalMs > 0) { + this.heartbeatTimer = setInterval(() => { + if (this.ws && this.ws.readyState === 1) { + try { + this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); + } catch { + // Heartbeat send failed + } + } + }, this.heartbeatIntervalMs); + + if (typeof this.heartbeatTimer === "object" && this.heartbeatTimer !== null && "unref" in this.heartbeatTimer) { + (this.heartbeatTimer as NodeJS.Timeout).unref(); + } + } + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer !== null) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + private cleanupSocket(): void { + this.connected = false; + this.stopHeartbeat(); + if (this.ws) { + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onmessage = null; + try { + if (this.ws.readyState === 0 || this.ws.readyState === 1) { + this.ws.close(); + } + } catch { + // Ignore close error + } + this.ws = null; + } + } + + disconnect(): void { + this.stopped = true; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.cleanupSocket(); + } + + // ------------------------------------------------------------------------- + // Event Emitter Helpers + // ------------------------------------------------------------------------- + + on(event: "connect", cb: () => void): this; + on(event: "disconnect", cb: (reason?: string) => void): this; + on(event: "reconnect", cb: (attempt: number, max: number) => void): this; + on(event: "reconnect_failed", cb: (attempts: number) => void): this; + on(event: "error", cb: (err: Error) => void): this; + on(event: string, cb: (...args: any[]) => void): this { + switch (event) { + case "connect": + this.onConnectListeners.add(cb as () => void); + break; + case "disconnect": + this.onDisconnectListeners.add(cb as (reason?: string) => void); + break; + case "reconnect": + this.onReconnectListeners.add(cb as (attempt: number, max: number) => void); + break; + case "reconnect_failed": + this.onReconnectFailedListeners.add(cb as (attempts: number) => void); + break; + case "error": + this.onErrorListeners.add(cb as (err: Error) => void); + break; + } + return this; + } +} diff --git a/src/utils.ts b/src/utils.ts index 41b1c83..12620c2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -48,9 +48,24 @@ export function formatAmount(stroops: bigint): string { * parseAmount("0.00000015"); // 1n (8th decimal is truncated) */ export function parseAmount(value: string): bigint { - const [whole = "0", frac = ""] = value.split("."); + const isNegative = value.startsWith("-"); + const clean = isNegative ? value.slice(1) : value; + const parts = clean.split("."); + if (parts.length > 2) { + throw new SyntaxError(`Invalid amount "${value}": multiple decimal points`); + } + const [whole = "0", frac = ""] = parts; + if (!/^[+]?\d*$/.test(whole)) { + throw new SyntaxError(`Invalid whole part "${whole}"`); + } + if (!/^\d*$/.test(frac)) { + throw new SyntaxError(`Invalid fractional part "${frac}"`); + } const fracPadded = frac.padEnd(7, "0").slice(0, 7); - return BigInt(whole) * STROOPS_PER_UNIT + BigInt(fracPadded); + const wholePart = whole === "" || whole === "+" ? 0n : BigInt(whole); + const fracPart = fracPadded === "" ? 0n : BigInt(fracPadded); + const total = wholePart * STROOPS_PER_UNIT + fracPart; + return isNegative ? -total : total; } export function groupBy>( diff --git a/src/webhookClient.ts b/src/webhookClient.ts new file mode 100644 index 0000000..38f57c5 --- /dev/null +++ b/src/webhookClient.ts @@ -0,0 +1,446 @@ +/** + * Webhook client library for receiving, verifying, and processing StellarSplit webhooks. + */ + +import { createHmac, timingSafeEqual } from "crypto"; +import type { IncomingMessage, ServerResponse } from "http"; + +export interface WebhookClientConfig { + /** The shared HMAC secret configured for the webhook endpoint. */ + secret: string; + /** Maximum allowed timestamp skew in seconds to prevent replay attacks. Default: 300 (5 minutes). */ + toleranceSeconds?: number; + /** Header name for signature. Default: "x-stellar-split-signature". */ + signatureHeader?: string; + /** Header name for timestamp. Default: "x-stellar-split-timestamp". */ + timestampHeader?: string; + /** Header name for event name. Default: "x-stellar-split-event". */ + eventHeader?: string; + /** Whether process() throws errors or returns them in the result object. Default: true. */ + throwOnError?: boolean; +} + +export interface WebhookEventPayload { + id?: string; + event: string; + invoiceId?: string; + timestamp: string | number; + data: T; + [key: string]: unknown; +} + +export interface WebhookProcessResult { + success: boolean; + event?: WebhookEventPayload; + handlersExecuted: number; + error?: Error; + timestamp?: number; +} + +export type WebhookHandler = ( + payload: WebhookEventPayload, +) => void | Promise; + +export type WebhookErrorHandler = ( + error: Error, + rawBody?: string, + headers?: Record, +) => void | Promise; + +export type WebhookClientMiddleware = ( + payload: WebhookEventPayload, + next: () => Promise, +) => Promise; + +export type WebhookMiddleware = WebhookClientMiddleware; + +// --------------------------------------------------------------------------- +// Custom Errors +// --------------------------------------------------------------------------- + +export class WebhookError extends Error { + constructor(message: string) { + super(message); + this.name = "WebhookError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class WebhookVerificationError extends WebhookError { + constructor(message = "Webhook signature verification failed") { + super(message); + this.name = "WebhookVerificationError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class WebhookTimestampError extends WebhookError { + readonly timestamp: number; + readonly toleranceSeconds: number; + + constructor(message: string, timestamp: number, toleranceSeconds: number) { + super(message); + this.name = "WebhookTimestampError"; + this.timestamp = timestamp; + this.toleranceSeconds = toleranceSeconds; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class WebhookPayloadMalformedError extends WebhookError { + constructor(message = "Webhook payload is malformed or invalid JSON") { + super(message); + this.name = "WebhookPayloadMalformedError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// Webhook Client +// --------------------------------------------------------------------------- + +export class WebhookClient { + private readonly secret: string; + private readonly toleranceSeconds: number; + private readonly signatureHeader: string; + private readonly timestampHeader: string; + private readonly eventHeader: string; + private readonly throwOnError: boolean; + + private readonly handlers = new Map>(); + private readonly onceHandlers = new Map>(); + private readonly errorHandlers = new Set(); + private readonly middlewares: WebhookMiddleware[] = []; + + constructor(config: WebhookClientConfig) { + if (!config || !config.secret) { + throw new TypeError("WebhookClient requires a secret HMAC key"); + } + this.secret = config.secret; + this.toleranceSeconds = config.toleranceSeconds ?? 300; + this.signatureHeader = (config.signatureHeader ?? "x-stellar-split-signature").toLowerCase(); + this.timestampHeader = (config.timestampHeader ?? "x-stellar-split-timestamp").toLowerCase(); + this.eventHeader = (config.eventHeader ?? "x-stellar-split-event").toLowerCase(); + this.throwOnError = config.throwOnError ?? true; + } + + /** + * Register an event handler for a specific event type (e.g. "payment", "released", "refunded") + * or "*" to receive all events. + */ + on(event: string, handler: WebhookHandler): this { + let set = this.handlers.get(event); + if (!set) { + set = new Set(); + this.handlers.set(event, set); + } + set.add(handler as WebhookHandler); + return this; + } + + /** + * Register a one-time event handler. + */ + once(event: string, handler: WebhookHandler): this { + let set = this.onceHandlers.get(event); + if (!set) { + set = new Set(); + this.onceHandlers.set(event, set); + } + set.add(handler as WebhookHandler); + return this; + } + + /** + * Remove a registered event handler. + */ + off(event: string, handler: WebhookHandler): this { + this.handlers.get(event)?.delete(handler as WebhookHandler); + this.onceHandlers.get(event)?.delete(handler as WebhookHandler); + return this; + } + + /** + * Register an error handler. + */ + onError(handler: WebhookErrorHandler): this { + this.errorHandlers.add(handler); + return this; + } + + /** + * Register middleware executed before handlers. + */ + use(middleware: WebhookMiddleware): this { + this.middlewares.push(middleware); + return this; + } + + /** + * Helper to compute an expected HMAC-SHA256 signature for testing or manual signing. + */ + computeSignature(payload: string | Buffer, timestamp?: number): { signature: string; timestamp: number } { + const ts = timestamp ?? Math.floor(Date.now() / 1000); + const bodyStr = typeof payload === "string" ? payload : payload.toString("utf8"); + const signatureContent = `${ts}.${bodyStr}`; + const signature = createHmac("sha256", this.secret).update(signatureContent).digest("hex"); + return { signature, timestamp: ts }; + } + + /** + * Verify an incoming signature and timestamp against the raw body. + */ + verify( + rawBody: string | Buffer, + signatureHeader: string, + timestampHeader?: string, + ): { valid: boolean; timestamp: number; reason?: string } { + const bodyStr = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8"); + + let timestamp: number; + let expectedSig: string; + + // Check if signature header is in format "t=12345678,v1=abcdef..." + if (signatureHeader.includes("t=") && signatureHeader.includes("v1=")) { + const parts = signatureHeader.split(","); + const tPart = parts.find((p) => p.trim().startsWith("t="))?.trim().slice(2); + const v1Part = parts.find((p) => p.trim().startsWith("v1="))?.trim().slice(3); + + if (!tPart || !v1Part) { + return { valid: false, timestamp: 0, reason: "Malformed signature header format" }; + } + timestamp = Number(tPart); + expectedSig = v1Part; + } else { + if (!timestampHeader) { + // Fallback: check if signature was computed over raw body directly without timestamp + const directExpected = createHmac("sha256", this.secret).update(bodyStr).digest(); + if (/^[0-9a-f]+$/i.test(signatureHeader) && signatureHeader.length % 2 === 0) { + const directProvided = Buffer.from(signatureHeader, "hex"); + if (directExpected.length === directProvided.length && timingSafeEqual(directExpected, directProvided)) { + return { valid: true, timestamp: Math.floor(Date.now() / 1000) }; + } + } + return { valid: false, timestamp: 0, reason: "Missing timestamp" }; + } + timestamp = Number(timestampHeader); + expectedSig = signatureHeader; + } + + if (isNaN(timestamp) || timestamp <= 0) { + return { valid: false, timestamp: 0, reason: "Invalid timestamp" }; + } + + // Verify freshness against tolerance + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestamp) > this.toleranceSeconds) { + return { + valid: false, + timestamp, + reason: `Timestamp ${timestamp} outside tolerance of ${this.toleranceSeconds}s (drift: ${Math.abs(now - timestamp)}s)`, + }; + } + + // Compute expected HMAC + const computedDigest = createHmac("sha256", this.secret) + .update(`${timestamp}.${bodyStr}`) + .digest(); + + if (!/^[0-9a-f]+$/i.test(expectedSig) || expectedSig.length % 2 !== 0) { + return { valid: false, timestamp, reason: "Invalid hex signature format" }; + } + + const providedDigest = Buffer.from(expectedSig, "hex"); + if (computedDigest.length !== providedDigest.length) { + return { valid: false, timestamp, reason: "Signature length mismatch" }; + } + + const matched = timingSafeEqual(computedDigest, providedDigest); + if (!matched) { + return { valid: false, timestamp, reason: "Signature mismatch" }; + } + + return { valid: true, timestamp }; + } + + private extractHeader(headers: Record, name: string): string | undefined { + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) { + return Array.isArray(value) ? value[0] : value; + } + } + return undefined; + } + + /** + * Process an incoming webhook payload with headers. + */ + async process( + rawBody: string | Buffer, + headers: Record = {}, + ): Promise> { + const bodyStr = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8"); + + const signature = this.extractHeader(headers, this.signatureHeader); + const timestampHeader = this.extractHeader(headers, this.timestampHeader); + + try { + if (!signature) { + throw new WebhookVerificationError(`Missing signature header: ${this.signatureHeader}`); + } + + const verification = this.verify(rawBody, signature, timestampHeader); + if (!verification.valid) { + if (verification.reason?.includes("outside tolerance")) { + throw new WebhookTimestampError( + verification.reason, + verification.timestamp, + this.toleranceSeconds, + ); + } + throw new WebhookVerificationError(verification.reason ?? "Invalid signature"); + } + + // Parse JSON payload + let parsed: unknown; + try { + parsed = JSON.parse(bodyStr); + } catch (err) { + throw new WebhookPayloadMalformedError((err as Error).message); + } + + if (!parsed || typeof parsed !== "object") { + throw new WebhookPayloadMalformedError("Payload must be a JSON object"); + } + + const payloadObj = parsed as Record; + const eventName = + (payloadObj.event as string) || + (payloadObj.type as string) || + this.extractHeader(headers, this.eventHeader) || + "unknown"; + + const normalizedPayload: WebhookEventPayload = { + id: (payloadObj.id as string) || undefined, + event: eventName, + invoiceId: (payloadObj.invoiceId as string) || undefined, + timestamp: (payloadObj.timestamp as string | number) || verification.timestamp, + data: (payloadObj.data as T) ?? (payloadObj as unknown as T), + ...payloadObj, + }; + + // Collect handlers + const matchedHandlers: WebhookHandler[] = []; + const specific = this.handlers.get(eventName); + if (specific) { + for (const h of specific) matchedHandlers.push(h); + } + const wildcard = this.handlers.get("*"); + if (wildcard) { + for (const h of wildcard) matchedHandlers.push(h); + } + + // Collect once handlers + const specificOnce = this.onceHandlers.get(eventName); + if (specificOnce) { + for (const h of specificOnce) matchedHandlers.push(h); + this.onceHandlers.delete(eventName); + } + const wildcardOnce = this.onceHandlers.get("*"); + if (wildcardOnce) { + for (const h of wildcardOnce) matchedHandlers.push(h); + this.onceHandlers.delete("*"); + } + + // Run middleware chain then handlers + let index = -1; + const dispatch = async (i: number): Promise => { + if (i <= index) throw new Error("next() called multiple times"); + index = i; + if (i < this.middlewares.length) { + const fn = this.middlewares[i]!; + await fn(normalizedPayload as WebhookEventPayload, () => dispatch(i + 1)); + } else { + // Execute all handlers + for (const handler of matchedHandlers) { + await handler(normalizedPayload); + } + } + }; + + await dispatch(0); + + return { + success: true, + event: normalizedPayload, + handlersExecuted: matchedHandlers.length, + timestamp: verification.timestamp, + }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + + // Notify error handlers + for (const errHandler of this.errorHandlers) { + try { + await errHandler(error, bodyStr, headers); + } catch { + // Error handler failures should not swallow initial error + } + } + + if (this.throwOnError) { + throw error; + } + + return { + success: false, + handlersExecuted: 0, + error, + }; + } + } + + /** + * Create an HTTP request listener compatible with Node.js http.createServer or Express. + */ + createHttpHandler(): (req: IncomingMessage, res: ServerResponse) => Promise { + return (req: IncomingMessage, res: ServerResponse): Promise => { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + + req.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + req.on("error", (err) => { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + resolve(); + }); + + req.on("end", async () => { + const rawBody = Buffer.concat(chunks); + const headers = req.headers as Record; + + try { + const result = await this.process(rawBody, headers); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ received: true, event: result.event?.event })); + resolve(); + } catch (err) { + const statusCode = + err instanceof WebhookVerificationError || + err instanceof WebhookTimestampError || + err instanceof WebhookPayloadMalformedError + ? 400 + : 500; + res.writeHead(statusCode, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: (err as Error).message })); + resolve(); + } + }); + }); + }; + } +} diff --git a/src/webhooks/index.ts b/src/webhooks/index.ts new file mode 100644 index 0000000..3469aa6 --- /dev/null +++ b/src/webhooks/index.ts @@ -0,0 +1,17 @@ +export { + WebhookClient, + WebhookError, + WebhookVerificationError, + WebhookTimestampError, + WebhookPayloadMalformedError, +} from "../webhookClient.js"; +export type { + WebhookClientConfig, + WebhookEventPayload, + WebhookProcessResult, + WebhookHandler, + WebhookErrorHandler, + WebhookMiddleware, + WebhookClientMiddleware, +} from "../webhookClient.js"; +export { verifyWebhookSignature } from "./verify.js"; diff --git a/test/analyticsTracking.test.ts b/test/analyticsTracking.test.ts new file mode 100644 index 0000000..c0e54c0 --- /dev/null +++ b/test/analyticsTracking.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + AnalyticsTracker, + PrivacyController, + AnalyticsCollector, + wrapClientWithAnalytics, +} from "../src/analyticsTracking.js"; + +describe("Analytics Tracking (#1036)", () => { + describe("Opt-in & Lifecycle Controls", () => { + it("is disabled by default (strictly opt-in)", () => { + const tracker = new AnalyticsTracker(); + expect(tracker.isEnabled()).toBe(false); + + tracker.track({ name: "test_event" }); + const report = tracker.generateReport(); + expect(report.totalEvents).toBe(0); + expect(report.totalMethodCalls).toBe(0); + }); + + it("can opt in explicitly", () => { + const tracker = new AnalyticsTracker({ enabled: false }); + expect(tracker.isEnabled()).toBe(false); + + tracker.optIn(); + expect(tracker.isEnabled()).toBe(true); + + tracker.track({ name: "test_event" }); + const report = tracker.generateReport(); + expect(report.totalEvents).toBe(1); + }); + + it("can opt out and clears accumulated state", () => { + const tracker = new AnalyticsTracker({ enabled: true }); + tracker.track({ name: "event_1" }); + expect(tracker.generateReport().totalEvents).toBe(1); + + tracker.optOut(); + expect(tracker.isEnabled()).toBe(false); + expect(tracker.generateReport().totalEvents).toBe(0); + + tracker.track({ name: "event_2" }); + expect(tracker.generateReport().totalEvents).toBe(0); + }); + }); + + describe("Privacy Controls", () => { + const validGAddress = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; + const validSSecret = "SCZANGBA5YHTNYVVV4C3U252E2B6P6F5T3U6MM63WBSBZATAQI3EBTQ4"; + + it("redacts Stellar secret keys (S...)", () => { + const controller = new PrivacyController({ stripSecrets: true }); + const sanitized = controller.sanitizeString(`Signing with ${validSSecret}`); + expect(sanitized).toBe("Signing with [REDACTED_SECRET]"); + expect(sanitized).not.toContain(validSSecret); + }); + + it("anonymizes Stellar public keys (G...) in anonymized mode", () => { + const controller = new PrivacyController({ level: "anonymized", anonymizeAddresses: true }); + const sanitized = controller.sanitizeString(validGAddress); + expect(sanitized).not.toBe(validGAddress); + expect(sanitized.startsWith("GA7Q...")).toBe(true); + expect(sanitized.endsWith("...VSGZ")).toBe(true); + }); + + it("redacts addresses completely in strict mode", () => { + const controller = new PrivacyController({ level: "strict" }); + const sanitized = controller.anonymizeAddress(validGAddress); + expect(sanitized).toBe("[REDACTED_ADDRESS]"); + + const props = controller.sanitizeProperties({ + user: validGAddress, + amount: 100, + }); + expect(props).toEqual({}); + }); + + it("redacts PII fields like email, ip, and password", () => { + const controller = new PrivacyController({ allowPII: false }); + const sanitized = controller.sanitizeProperties({ + email: "alice@example.com", + ip: "192.168.1.1", + password: "supersecretpassword", + memo: "payment invoice 123", + }); + + expect(sanitized["email"]).toBe("[REDACTED_PII]"); + expect(sanitized["ip"]).toBe("[REDACTED_PII]"); + expect(sanitized["password"]).toBe("[REDACTED_PII]"); + expect(sanitized["memo"]).toBe("payment invoice 123"); + }); + + it("tracks events with privacy sanitization applied", () => { + const tracker = new AnalyticsTracker({ + enabled: true, + privacy: { level: "anonymized" }, + }); + + tracker.track({ + name: "create_invoice", + category: "invoices", + properties: { + creator: validGAddress, + secretKey: validSSecret, + email: "creator@example.com", + amount: 500, + }, + }); + + const report = tracker.generateReport(); + expect(report.totalEvents).toBe(1); + const event = report.events[0]; + expect(event?.properties["secretKey"]).toBe("[REDACTED_SECRET]"); + expect(event?.properties["email"]).toBe("[REDACTED_PII]"); + expect(event?.properties["creator"]).not.toBe(validGAddress); + expect(event?.properties["amount"]).toBe(500); + }); + }); + + describe("Collector & Accurate Reporting", () => { + it("accumulates method latencies and computes accurate statistics", () => { + const tracker = new AnalyticsTracker({ enabled: true }); + + tracker.trackMethod("getInvoice", 10, true); + tracker.trackMethod("getInvoice", 20, true); + tracker.trackMethod("getInvoice", 30, true); + tracker.trackMethod("getInvoice", 40, false); + + const report = tracker.generateReport(); + const methodStats = report.methods["getInvoice"]; + + expect(methodStats).toBeDefined(); + expect(methodStats?.calls).toBe(4); + expect(methodStats?.errors).toBe(1); + expect(methodStats?.errorRate).toBe(0.25); + expect(methodStats?.minDurationMs).toBe(10); + expect(methodStats?.maxDurationMs).toBe(40); + expect(methodStats?.avgDurationMs).toBe(25); + expect(methodStats?.p50DurationMs).toBe(20); + expect(methodStats?.p95DurationMs).toBe(40); + }); + + it("tracks custom metrics and error rates accurately", () => { + const tracker = new AnalyticsTracker({ enabled: true }); + + tracker.trackMetric("retries", 3); + tracker.trackMetric("retries", 2); + tracker.trackError(new Error("RPC Timeout"), { code: 504 }); + + const report = tracker.generateReport(); + expect(report.metrics["retries"]).toBe(5); + expect(report.metrics["error_count"]).toBe(1); + expect(report.eventsByCategory["errors"]).toBe(1); + }); + + it("limits queue size and evicts oldest events", () => { + const collector = new AnalyticsCollector(3); + collector.recordEvent({ name: "e1", category: "c", properties: {}, timestamp: 1 }); + collector.recordEvent({ name: "e2", category: "c", properties: {}, timestamp: 2 }); + collector.recordEvent({ name: "e3", category: "c", properties: {}, timestamp: 3 }); + collector.recordEvent({ name: "e4", category: "c", properties: {}, timestamp: 4 }); + + const events = collector.getEvents(); + expect(events.length).toBe(3); + expect(events[0]?.name).toBe("e2"); + expect(events[2]?.name).toBe("e4"); + }); + }); + + describe("Reporting & Flush", () => { + it("invokes onReport callback when generating report or flushing", async () => { + const onReport = vi.fn(); + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + const tracker = new AnalyticsTracker({ + enabled: true, + endpoint: "https://telemetry.stellar.org/v1/metrics", + onReport, + fetchFn: mockFetch as unknown as typeof fetch, + }); + + tracker.track({ name: "invoice_paid" }); + const flushedReport = await tracker.flush(); + + expect(onReport).toHaveBeenCalledTimes(1); + expect(flushedReport?.totalEvents).toBe(1); + expect(mockFetch).toHaveBeenCalledWith( + "https://telemetry.stellar.org/v1/metrics", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + + // State is reset after flush + expect(tracker.generateReport().totalEvents).toBe(0); + }); + }); + + describe("wrapClientWithAnalytics Proxy", () => { + it("returns unwrapped client when analytics is disabled", () => { + const tracker = new AnalyticsTracker({ enabled: false }); + const client = { + fetchData: () => "data", + }; + + const proxied = wrapClientWithAnalytics(client, tracker); + expect(proxied).toBe(client); + }); + + it("intercepts successful and failing calls on wrapped client", async () => { + const tracker = new AnalyticsTracker({ enabled: true }); + const client = { + async getInvoice(id: string) { + return { id }; + }, + async failOperation() { + throw new Error("Simulation failure"); + }, + }; + + const proxied = wrapClientWithAnalytics(client, tracker); + + await proxied.getInvoice("inv-123"); + await expect(proxied.failOperation()).rejects.toThrow("Simulation failure"); + + const report = tracker.generateReport(); + expect(report.methods["getInvoice"]?.calls).toBe(1); + expect(report.methods["getInvoice"]?.errors).toBe(0); + expect(report.methods["failOperation"]?.calls).toBe(1); + expect(report.methods["failOperation"]?.errors).toBe(1); + }); + }); +}); diff --git a/test/invoiceStateSubscription.test.ts b/test/invoiceStateSubscription.test.ts new file mode 100644 index 0000000..ee0e3f2 --- /dev/null +++ b/test/invoiceStateSubscription.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + InvoiceStateSubscriber, + matchesFilter, + type InvoiceStateUpdate, +} from "../src/invoiceStateSubscription.js"; +import { EventEmitter } from "events"; + +class MockWebSocket extends EventEmitter { + static instances: MockWebSocket[] = []; + readyState = 0; // CONNECTING + url: string; + sentMessages: string[] = []; + + constructor(url: string) { + super(); + this.url = url; + MockWebSocket.instances.push(this); + // Simulate async open + setTimeout(() => { + this.readyState = 1; // OPEN + if (this.onopen) this.onopen(); + this.emit("open"); + }, 10); + } + + onopen: (() => void) | null = null; + onclose: ((event: { reason?: string }) => void) | null = null; + onerror: ((err: any) => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + + send(data: string) { + this.sentMessages.push(data); + } + + close(code = 1000, reason = "Normal closure") { + this.readyState = 3; // CLOSED + if (this.onclose) this.onclose({ reason }); + this.emit("close", { reason }); + } + + simulateServerMessage(payload: Record) { + const data = JSON.stringify(payload); + if (this.onmessage) { + this.onmessage({ data }); + } + this.emit("message", { data }); + } +} + +describe("Invoice State Subscription (#1034)", () => { + beforeEach(() => { + MockWebSocket.instances = []; + }); + + describe("Filtering Logic", () => { + const sampleUpdate: InvoiceStateUpdate = { + invoiceId: "inv-100", + status: "Released", + funded: 50_000_000n, + timestamp: Date.now(), + lastPayment: { + payer: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + amount: 25_000_000n, + timestamp: Date.now(), + }, + metadata: { + creator: "GCREATOR123", + }, + }; + + it("matches wildcard invoiceId '*'", () => { + expect(matchesFilter(sampleUpdate, { invoiceId: "*" })).toBe(true); + }); + + it("filters by single invoiceId", () => { + expect(matchesFilter(sampleUpdate, { invoiceId: "inv-100" })).toBe(true); + expect(matchesFilter(sampleUpdate, { invoiceId: "inv-999" })).toBe(false); + }); + + it("filters by array of invoiceIds", () => { + expect(matchesFilter(sampleUpdate, { invoiceId: ["inv-1", "inv-100"] })).toBe(true); + expect(matchesFilter(sampleUpdate, { invoiceId: ["inv-1", "inv-2"] })).toBe(false); + }); + + it("filters by status", () => { + expect(matchesFilter(sampleUpdate, { status: "Released" })).toBe(true); + expect(matchesFilter(sampleUpdate, { status: "Pending" })).toBe(false); + expect(matchesFilter(sampleUpdate, { status: ["Pending", "Released"] })).toBe(true); + expect(matchesFilter(sampleUpdate, { status: ["Cancelled", "Refunded"] })).toBe(false); + }); + + it("filters by creator", () => { + expect(matchesFilter(sampleUpdate, { creator: "gcreator123" })).toBe(true); + expect(matchesFilter(sampleUpdate, { creator: "gothercreator" })).toBe(false); + }); + + it("filters by payer", () => { + expect( + matchesFilter(sampleUpdate, { + payer: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + }), + ).toBe(true); + expect(matchesFilter(sampleUpdate, { payer: "GUNKNOWN" })).toBe(false); + }); + + it("filters by minimum funded amount", () => { + expect(matchesFilter(sampleUpdate, { minFunded: 40_000_000n })).toBe(true); + expect(matchesFilter(sampleUpdate, { minFunded: 50_000_000n })).toBe(true); + expect(matchesFilter(sampleUpdate, { minFunded: 60_000_000n })).toBe(false); + }); + + it("evaluates custom predicate filter", () => { + expect(matchesFilter(sampleUpdate, { custom: (u) => u.funded > 10_000_000n })).toBe(true); + expect(matchesFilter(sampleUpdate, { custom: (u) => u.status === "Pending" })).toBe(false); + }); + }); + + describe("Real-time WebSocket Subscriptions", () => { + it("subscribes and receives real-time invoice state updates", async () => { + const subscriber = new InvoiceStateSubscriber({ + wsUrl: "ws://localhost:9000", + wsFactory: () => new MockWebSocket("ws://localhost:9000") as unknown as WebSocket, + }); + + const callback = vi.fn(); + const handle = subscriber.subscribe({ invoiceId: "inv-42" }, callback); + + // Wait for socket to open + await new Promise((r) => setTimeout(r, 20)); + const ws = MockWebSocket.instances[0]!; + + // Simulate incoming message + ws.simulateServerMessage({ + invoiceId: "inv-42", + status: "Pending", + funded: "10000000", + timestamp: Date.now(), + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + invoiceId: "inv-42", + status: "Pending", + funded: 10_000_000n, + }), + ); + + // Unsubscribe stops delivery + handle.unsubscribe(); + expect(handle.isActive).toBe(false); + + ws.simulateServerMessage({ + invoiceId: "inv-42", + status: "Released", + funded: "10000000", + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("supports dynamic filter updates via updateFilter()", async () => { + const subscriber = new InvoiceStateSubscriber({ + wsUrl: "ws://localhost:9000", + wsFactory: () => new MockWebSocket("ws://localhost:9000") as unknown as WebSocket, + }); + + const callback = vi.fn(); + const handle = subscriber.subscribe({ status: "Pending" }, callback); + + await new Promise((r) => setTimeout(r, 20)); + const ws = MockWebSocket.instances[0]!; + + ws.simulateServerMessage({ invoiceId: "inv-1", status: "Released", funded: 0 }); + expect(callback).not.toHaveBeenCalled(); + + // Update filter to accept "Released" + handle.updateFilter({ status: "Released" }); + + ws.simulateServerMessage({ invoiceId: "inv-2", status: "Released", funded: 0 }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ invoiceId: "inv-2" })); + + handle.unsubscribe(); + }); + }); + + describe("Reliable Reconnection", () => { + it("automatically reconnects and re-registers active subscriptions upon reconnect", async () => { + const subscriber = new InvoiceStateSubscriber({ + wsUrl: "ws://localhost:9000", + wsFactory: () => new MockWebSocket("ws://localhost:9000") as unknown as WebSocket, + reconnect: { + enabled: true, + baseDelayMs: 20, + maxDelayMs: 50, + maxAttempts: 3, + jitter: false, + }, + }); + + const onConnect = vi.fn(); + const onDisconnect = vi.fn(); + const onReconnect = vi.fn(); + + subscriber.on("connect", onConnect); + subscriber.on("disconnect", onDisconnect); + subscriber.on("reconnect", onReconnect); + + const callback = vi.fn(); + const handle = subscriber.subscribe("inv-auto-recon", callback); + + await new Promise((r) => setTimeout(r, 25)); + expect(onConnect).toHaveBeenCalledTimes(1); + + const ws1 = MockWebSocket.instances[0]!; + // Simulate unexpected socket drop + ws1.close(1006, "Abnormal closure"); + expect(onDisconnect).toHaveBeenCalledWith("Abnormal closure"); + + // Wait for reconnect delay + await new Promise((r) => setTimeout(r, 50)); + + expect(onReconnect).toHaveBeenCalledWith(1, 3); + expect(MockWebSocket.instances.length).toBe(2); + + const ws2 = MockWebSocket.instances[1]!; + await new Promise((r) => setTimeout(r, 25)); + expect(onConnect).toHaveBeenCalledTimes(2); + + // Verify that subscription receives events on reconnected socket + ws2.simulateServerMessage({ + invoiceId: "inv-auto-recon", + status: "Paid", + funded: "500", + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ invoiceId: "inv-auto-recon" }), + ); + + handle.unsubscribe(); + subscriber.disconnect(); + }); + + it("stops after maxAttempts and triggers reconnect_failed", async () => { + const subscriber = new InvoiceStateSubscriber({ + wsUrl: "ws://localhost:9000", + wsFactory: () => { + throw new Error("Connection refused"); + }, + reconnect: { + enabled: true, + baseDelayMs: 10, + maxDelayMs: 20, + maxAttempts: 2, + jitter: false, + }, + }); + + const onFailed = vi.fn(); + subscriber.on("reconnect_failed", onFailed); + + subscriber.subscribe("inv-fail", () => {}); + + // Wait for retry attempts to exhaust + await new Promise((r) => setTimeout(r, 60)); + + expect(onFailed).toHaveBeenCalledWith(2); + subscriber.disconnect(); + }); + }); +}); diff --git a/test/parseAmount.test.ts b/test/parseAmount.test.ts new file mode 100644 index 0000000..e983b55 --- /dev/null +++ b/test/parseAmount.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { parseAmount, formatAmount } from "../src/utils.js"; + +describe("parseAmount unit tests and edge cases (#1037)", () => { + describe("Valid inputs", () => { + it("parses whole integer strings", () => { + expect(parseAmount("0")).toBe(0n); + expect(parseAmount("1")).toBe(10_000_000n); + expect(parseAmount("10")).toBe(100_000_000n); + expect(parseAmount("1000")).toBe(10_000_000_000n); + expect(parseAmount("999999")).toBe(9_999_990_000_000n); + }); + + it("parses single decimal place", () => { + expect(parseAmount("0.1")).toBe(1_000_000n); + expect(parseAmount("1.5")).toBe(15_000_000n); + expect(parseAmount("2.9")).toBe(29_000_000n); + }); + + it("parses decimals up to 7 decimal places (stroops)", () => { + expect(parseAmount("0.01")).toBe(100_000n); + expect(parseAmount("0.001")).toBe(10_000n); + expect(parseAmount("0.0001")).toBe(1_000n); + expect(parseAmount("0.00001")).toBe(100n); + expect(parseAmount("0.000001")).toBe(10n); + expect(parseAmount("0.0000001")).toBe(1n); + expect(parseAmount("1.1234567")).toBe(11_234_567n); + expect(parseAmount("0.9999999")).toBe(9_999_999n); + }); + + it("parses leading zeros and trailing zeros", () => { + expect(parseAmount("001.5000")).toBe(15_000_000n); + expect(parseAmount("00000.0000000")).toBe(0n); + expect(parseAmount("10.0000000")).toBe(100_000_000n); + expect(parseAmount("0.0000000")).toBe(0n); + }); + + it("handles omitted whole part (.5)", () => { + expect(parseAmount(".5")).toBe(5_000_000n); + expect(parseAmount(".0000001")).toBe(1n); + expect(parseAmount(".0")).toBe(0n); + }); + + it("handles omitted fractional part (5.)", () => { + expect(parseAmount("5.")).toBe(50_000_000n); + expect(parseAmount("0.")).toBe(0n); + }); + + it("handles empty string defaulting to zero", () => { + expect(parseAmount("")).toBe(0n); + }); + + it("handles explicit positive sign", () => { + expect(parseAmount("+1")).toBe(10_000_000n); + expect(parseAmount("+1.5")).toBe(15_000_000n); + expect(parseAmount("+0.0000001")).toBe(1n); + }); + + it("handles negative amounts correctly", () => { + expect(parseAmount("-1")).toBe(-10_000_000n); + expect(parseAmount("-1.5")).toBe(-15_000_000n); + expect(parseAmount("-0.5")).toBe(-5_000_000n); + expect(parseAmount("-0.0000001")).toBe(-1n); + expect(parseAmount("-0")).toBe(0n); + expect(parseAmount("-0.0")).toBe(0n); + expect(parseAmount("-.5")).toBe(-5_000_000n); + }); + }); + + describe("Rounding and truncation", () => { + it("truncates 8th decimal place without rounding up", () => { + // 0.00000019 has '9' in the 8th position; must drop 9, returning 1 stroop (not 2) + expect(parseAmount("0.00000019")).toBe(1n); + expect(parseAmount("0.00000015")).toBe(1n); + expect(parseAmount("0.00000011")).toBe(1n); + }); + + it("truncates sub-stroop amounts to 0", () => { + expect(parseAmount("0.00000009")).toBe(0n); + expect(parseAmount("0.00000001")).toBe(0n); + expect(parseAmount("0.0000000000001")).toBe(0n); + }); + + it("truncates 8th and beyond decimal digits on large numbers", () => { + expect(parseAmount("123.4567890123")).toBe(1_234_567_890n); + expect(parseAmount("0.999999999999")).toBe(9_999_999n); + }); + + it("preserves exact precision up to 7 decimal digits", () => { + const original = "12345.6789012"; + const parsed = parseAmount(original); + expect(formatAmount(parsed)).toBe(original); + }); + }); + + describe("Boundaries", () => { + it("handles absolute zero", () => { + expect(parseAmount("0")).toBe(0n); + expect(parseAmount("0.0")).toBe(0n); + expect(parseAmount("0.0000000")).toBe(0n); + }); + + it("handles minimum non-zero representable stroop amount (1 stroop = 0.0000001)", () => { + expect(parseAmount("0.0000001")).toBe(1n); + }); + + it("handles maximum i64 stroops (922337203685.4775807)", () => { + const maxI64Stroops = 9223372036854775807n; + expect(parseAmount("922337203685.4775807")).toBe(maxI64Stroops); + }); + + it("handles large amounts exceeding 64-bit integers", () => { + const largeDecimal = "1000000000000000.1234567"; + const expected = 10000000000000001234567n; + expect(parseAmount(largeDecimal)).toBe(expected); + }); + }); + + describe("Invalid inputs & error handling", () => { + it("throws SyntaxError on multiple decimal points", () => { + expect(() => parseAmount("1.2.3")).toThrow(SyntaxError); + expect(() => parseAmount("1..2")).toThrow(SyntaxError); + expect(() => parseAmount("..")).toThrow(SyntaxError); + expect(() => parseAmount("1.0.0.0")).toThrow(SyntaxError); + }); + + it("throws SyntaxError on non-numeric whole parts", () => { + expect(() => parseAmount("abc")).toThrow(SyntaxError); + expect(() => parseAmount("abc.123")).toThrow(SyntaxError); + expect(() => parseAmount("foo")).toThrow(SyntaxError); + }); + + it("throws SyntaxError on non-numeric fractional parts", () => { + expect(() => parseAmount("1.2x")).toThrow(SyntaxError); + expect(() => parseAmount("0.abc")).toThrow(SyntaxError); + expect(() => parseAmount("1.0000000x")).toThrow(SyntaxError); + }); + + it("throws SyntaxError on special numbers and strings", () => { + expect(() => parseAmount("NaN")).toThrow(SyntaxError); + expect(() => parseAmount("Infinity")).toThrow(SyntaxError); + expect(() => parseAmount("-Infinity")).toThrow(SyntaxError); + }); + + it("throws SyntaxError on scientific notation or hex", () => { + expect(() => parseAmount("1e6")).toThrow(SyntaxError); + expect(() => parseAmount("1e-7")).toThrow(SyntaxError); + expect(() => parseAmount("0x10")).toThrow(SyntaxError); + }); + + it("throws SyntaxError on currency symbols or comma formatting", () => { + expect(() => parseAmount("$100")).toThrow(SyntaxError); + expect(() => parseAmount("1,000.50")).toThrow(SyntaxError); + expect(() => parseAmount("100 USDC")).toThrow(SyntaxError); + }); + + it("throws SyntaxError on embedded whitespace", () => { + expect(() => parseAmount("1 0")).toThrow(SyntaxError); + expect(() => parseAmount("1. 5")).toThrow(SyntaxError); + }); + }); +}); diff --git a/test/sdkExports.test.ts b/test/sdkExports.test.ts index 942cd0b..95f4ef0 100644 --- a/test/sdkExports.test.ts +++ b/test/sdkExports.test.ts @@ -24,4 +24,17 @@ describe("public API surface (issues #586, #588, #589)", () => { expect(typeof sdk.WalletConnectionTimeoutError).toBe("function"); expect(typeof sdk.isWalletConnectionTimeoutError).toBe("function"); }); + + it("exports parseAmount, InvoiceStateSubscriber, WebhookClient, and AnalyticsTracker (#1034, #1035, #1036, #1037)", () => { + expect(typeof sdk.parseAmount).toBe("function"); + expect(typeof sdk.InvoiceStateSubscriber).toBe("function"); + expect(typeof sdk.matchesFilter).toBe("function"); + expect(typeof sdk.WebhookClient).toBe("function"); + expect(typeof sdk.WebhookVerificationError).toBe("function"); + expect(typeof sdk.AnalyticsTracker).toBe("function"); + expect(typeof sdk.AnalyticsCollector).toBe("function"); + expect(typeof sdk.PrivacyController).toBe("function"); + expect(typeof sdk.wrapClientWithAnalytics).toBe("function"); + }); }); + diff --git a/test/webhookClient.test.ts b/test/webhookClient.test.ts new file mode 100644 index 0000000..ebb9090 --- /dev/null +++ b/test/webhookClient.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi } from "vitest"; +import { + WebhookClient, + WebhookVerificationError, + WebhookTimestampError, + WebhookPayloadMalformedError, +} from "../src/webhookClient.js"; +import { EventEmitter } from "events"; +import type { IncomingMessage, ServerResponse } from "http"; + +describe("Webhook Client (#1035)", () => { + const secret = "whsec_test_secret_1234567890abcdef"; + + describe("Verification", () => { + it("successfully verifies valid signature and timestamp", () => { + const client = new WebhookClient({ secret, toleranceSeconds: 300 }); + const payload = JSON.stringify({ event: "payment", invoiceId: "inv-1", amount: "100" }); + const { signature, timestamp } = client.computeSignature(payload); + + const verification = client.verify(payload, signature, String(timestamp)); + expect(verification.valid).toBe(true); + expect(verification.timestamp).toBe(timestamp); + }); + + it("verifies combined t=...,v1=... signature header format", () => { + const client = new WebhookClient({ secret }); + const payload = JSON.stringify({ event: "released", invoiceId: "inv-2" }); + const { signature, timestamp } = client.computeSignature(payload); + + const header = `t=${timestamp},v1=${signature}`; + const verification = client.verify(payload, header); + expect(verification.valid).toBe(true); + expect(verification.timestamp).toBe(timestamp); + }); + + it("rejects tampered payload", () => { + const client = new WebhookClient({ secret }); + const payload = JSON.stringify({ event: "payment", amount: "100" }); + const { signature, timestamp } = client.computeSignature(payload); + + const tampered = JSON.stringify({ event: "payment", amount: "1000" }); + const verification = client.verify(tampered, signature, String(timestamp)); + expect(verification.valid).toBe(false); + expect(verification.reason).toContain("Signature mismatch"); + }); + + it("rejects wrong secret key", () => { + const client = new WebhookClient({ secret: "correct_secret" }); + const badClient = new WebhookClient({ secret: "wrong_secret" }); + const payload = JSON.stringify({ event: "payment" }); + const { signature, timestamp } = badClient.computeSignature(payload); + + const verification = client.verify(payload, signature, String(timestamp)); + expect(verification.valid).toBe(false); + }); + + it("rejects timestamps outside tolerance window (replay attack prevention)", () => { + const client = new WebhookClient({ secret, toleranceSeconds: 60 }); + const payload = JSON.stringify({ event: "payment" }); + const oldTimestamp = Math.floor(Date.now() / 1000) - 120; // 2 minutes ago + const { signature } = client.computeSignature(payload, oldTimestamp); + + const verification = client.verify(payload, signature, String(oldTimestamp)); + expect(verification.valid).toBe(false); + expect(verification.reason).toContain("outside tolerance"); + }); + }); + + describe("Event Processing & Handlers", () => { + it("dispatches events to registered event-specific handler", async () => { + const client = new WebhookClient({ secret }); + const paymentHandler = vi.fn(); + const releaseHandler = vi.fn(); + + client.on("payment", paymentHandler); + client.on("released", releaseHandler); + + const payload = JSON.stringify({ + event: "payment", + invoiceId: "inv-42", + data: { amount: "50" }, + }); + const { signature, timestamp } = client.computeSignature(payload); + + const result = await client.process(payload, { + "x-stellar-split-signature": signature, + "x-stellar-split-timestamp": String(timestamp), + }); + + expect(result.success).toBe(true); + expect(result.handlersExecuted).toBe(1); + expect(paymentHandler).toHaveBeenCalledTimes(1); + expect(paymentHandler).toHaveBeenCalledWith( + expect.objectContaining({ + event: "payment", + invoiceId: "inv-42", + data: { amount: "50" }, + }), + ); + expect(releaseHandler).not.toHaveBeenCalled(); + }); + + it("dispatches events to wildcard '*' handlers", async () => { + const client = new WebhookClient({ secret }); + const wildcardHandler = vi.fn(); + client.on("*", wildcardHandler); + + const payload = JSON.stringify({ event: "refunded", invoiceId: "inv-99" }); + const { signature, timestamp } = client.computeSignature(payload); + + const result = await client.process(payload, { + "x-stellar-split-signature": signature, + "x-stellar-split-timestamp": String(timestamp), + }); + + expect(result.handlersExecuted).toBe(1); + expect(wildcardHandler).toHaveBeenCalledWith( + expect.objectContaining({ event: "refunded", invoiceId: "inv-99" }), + ); + }); + + it("executes once() handlers only once", async () => { + const client = new WebhookClient({ secret }); + const onceHandler = vi.fn(); + client.once("payment", onceHandler); + + const payload = JSON.stringify({ event: "payment" }); + const { signature, timestamp } = client.computeSignature(payload); + const headers = { + "x-stellar-split-signature": signature, + "x-stellar-split-timestamp": String(timestamp), + }; + + await client.process(payload, headers); + await client.process(payload, headers); + + expect(onceHandler).toHaveBeenCalledTimes(1); + }); + + it("unregisters handlers via off()", async () => { + const client = new WebhookClient({ secret }); + const handler = vi.fn(); + client.on("payment", handler); + client.off("payment", handler); + + const payload = JSON.stringify({ event: "payment" }); + const { signature, timestamp } = client.computeSignature(payload); + await client.process(payload, { + "x-stellar-split-signature": signature, + "x-stellar-split-timestamp": String(timestamp), + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("executes middleware pipeline before handlers", async () => { + const client = new WebhookClient({ secret }); + const order: string[] = []; + + client.use(async (payload, next) => { + order.push("middleware-1-before"); + await next(); + order.push("middleware-1-after"); + }); + + client.on("payment", () => { + order.push("handler"); + }); + + const payload = JSON.stringify({ event: "payment" }); + const { signature, timestamp } = client.computeSignature(payload); + await client.process(payload, { + "x-stellar-split-signature": signature, + "x-stellar-split-timestamp": String(timestamp), + }); + + expect(order).toEqual(["middleware-1-before", "handler", "middleware-1-after"]); + }); + }); + + describe("Error Handling", () => { + it("throws WebhookVerificationError on missing signature", async () => { + const client = new WebhookClient({ secret }); + const payload = JSON.stringify({ event: "payment" }); + + await expect(client.process(payload, {})).rejects.toThrow(WebhookVerificationError); + }); + + it("throws WebhookTimestampError on expired timestamp", async () => { + const client = new WebhookClient({ secret, toleranceSeconds: 10 }); + const payload = JSON.stringify({ event: "payment" }); + const oldTime = Math.floor(Date.now() / 1000) - 30; + const { signature } = client.computeSignature(payload, oldTime); + + await expect( + client.process(payload, { + "x-stellar-split-signature": signature, + "x-stellar-split-timestamp": String(oldTime), + }), + ).rejects.toThrow(WebhookTimestampError); + }); + + it("throws WebhookPayloadMalformedError on invalid JSON", async () => { + const client = new WebhookClient({ secret }); + const invalidJson = "{ invalid json: true"; + const { signature, timestamp } = client.computeSignature(invalidJson); + + await expect( + client.process(invalidJson, { + "x-stellar-split-signature": signature, + "x-stellar-split-timestamp": String(timestamp), + }), + ).rejects.toThrow(WebhookPayloadMalformedError); + }); + + it("returns error result without throwing when throwOnError is false", async () => { + const client = new WebhookClient({ secret, throwOnError: false }); + const result = await client.process("invalid", {}); + + expect(result.success).toBe(false); + expect(result.error).toBeInstanceOf(WebhookVerificationError); + }); + + it("notifies onError callback on failures", async () => { + const client = new WebhookClient({ secret, throwOnError: false }); + const onError = vi.fn(); + client.onError(onError); + + await client.process("{}", {}); + + expect(onError).toHaveBeenCalledWith( + expect.any(WebhookVerificationError), + "{}", + {}, + ); + }); + }); + + describe("HTTP Request Adapter", () => { + it("handles mock IncomingMessage and responds with 200 on success", async () => { + const client = new WebhookClient({ secret }); + const handler = vi.fn(); + client.on("invoice.created", handler); + + const httpHandler = client.createHttpHandler(); + + const payload = JSON.stringify({ event: "invoice.created", id: "10" }); + const { signature, timestamp } = client.computeSignature(payload); + + const req = new EventEmitter() as unknown as IncomingMessage; + req.headers = { + "x-stellar-split-signature": signature, + "x-stellar-split-timestamp": String(timestamp), + }; + + const res = { + writeHead: vi.fn(), + end: vi.fn(), + } as unknown as ServerResponse; + + const promise = httpHandler(req, res); + + req.emit("data", Buffer.from(payload)); + req.emit("end"); + + await promise; + + expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(handler).toHaveBeenCalled(); + }); + + it("responds with 400 when signature verification fails", async () => { + const client = new WebhookClient({ secret }); + const httpHandler = client.createHttpHandler(); + + const req = new EventEmitter() as unknown as IncomingMessage; + req.headers = {}; + + const res = { + writeHead: vi.fn(), + end: vi.fn(), + } as unknown as ServerResponse; + + const promise = httpHandler(req, res); + req.emit("data", Buffer.from("{}")); + req.emit("end"); + + await promise; + + expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object)); + }); + }); +});