diff --git a/.gitignore b/.gitignore index 916fbbd..4476c7d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage/ .env.*.local .npmrc *.tgz +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index aebda83..154b7dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security +- **Webhook signature header hardening.** `Webhooks.constructEvent` now joins multi-value `Langos-Signature` headers (string[] from proxies that split duplicate headers) with `,` so every `v1=` entry from every copy is considered — previously only `[0]` was used and signatures from rotation copies were silently dropped. Empty arrays now throw a clear `LangosSignatureVerificationError` instead of crashing on `[0]!`. Signature headers longer than 4096 characters are rejected before parsing as a CPU-DoS guard against attacker-controlled input. + +### Changed +- **Retry-After cap raised from 32s to 5 minutes** and made configurable via the new `maxRetryAfterMs` client option. The previous cap silently truncated realistic rate-limit windows (60–300s), defeating the header. Server-supplied values that are negative, NaN, or exceed the cap now fall back to exponential backoff (rather than silently honoring an attacker-controlled "sleep for 3 years" value). +- **`Webhooks.constructEvent` no longer conflates malformed-JSON payloads with signature failure.** Bodies that pass HMAC verification but fail `JSON.parse` now throw the new `LangosWebhookPayloadError` instead of `LangosSignatureVerificationError` — the two have different operational responses (file an upstream ticket vs rotate the secret). + +### Tests +- **`LangosTimeoutError` path**: four tests covering configured timeout, `timeoutMs` value, message content, and per-call `RequestOptions.timeout` override +- **`LangosConnectionError` path**: four tests covering DNS/refused errors, `.cause` propagation, message content, and retry exhaustion (3 total fetch calls on `maxRetries: 2`) +- **`AbortSignal` propagation**: three tests — pre-aborted signal, mid-flight abort, no-retry when signal fires — documenting current raw-throw behaviour (see real bug fix below) +- **Idempotency-Key reuse across retries**: three tests verifying the same auto-generated UUID is sent on every POST attempt, user-supplied key is preserved, and independent calls get distinct keys +- **Retry-After parsing**: eight tests — numeric (small values, capped per `maxRetryAfterMs`), HTTP-date (falls back to exponential), negative, NaN, missing, and two integration tests + ### Added +- **`LangosAbortError`.** New error class thrown when a partner-supplied `AbortSignal` cancels an in-flight request. Previously the SDK re-threw the raw `AbortError` DOMException, making cancellation indistinguishable from generic network failures. +- **`LangosResponseFormatError`.** New error class thrown when a 2xx response has a non-JSON content-type (e.g. SPA HTML at a misconfigured baseUrl). Prevents the SDK from silently casting garbage into resource objects. +- **`LangosWebhookPayloadError`.** New error class for webhook payloads that pass signature verification but cannot be parsed as JSON. Re-exported from the package root. +- **`maxRetryAfterMs` client option.** Configurable upper bound (default `300_000`) on how long the SDK will wait when honoring a `Retry-After` header. - **`client.challenges` resource.** `list({status, language, limit, cursor})` and `retrieve(id)` for the read-only `/v1/challenges` and `/v1/challenges/:id` endpoints. Lets partners discover available coding challenges in the customer's library before assigning them to candidates. - **`Challenge`, `ChallengeListParams`, `ChallengeStatus` types.** Re-exported from the package root. - **`challengeFromWire` transform** with unit-test coverage for both fully-populated and minimum-fields shapes. +- **`BillingCycle` and `AccountStatus` exported types.** Narrowed unions matching the server contract; partners can now discriminate on these without retyping enum literals. + +### Changed +- **Eliminated `any` from wire-handling code.** Every `*FromWire` transform and every `this.get<...>` / `this.post<...>` resource call is now typed against an internal `Wire` interface (declared in `src/core/transform.ts`). The SDK no longer takes raw `any` from the HTTP boundary; mismatches surface at compile time. +- **Narrowed `Account.billingCycle`.** Was `string`; now `BillingCycle | null` where `BillingCycle = 'monthly' | 'yearly'`. The server emits `'monthly'` or `'yearly'` (Stripe-aligned); free-tier accounts with no Stripe subscription map to `null`. **Forward-compat:** unknown future values (e.g. `'quarterly'`) collapse to `null` rather than throw. +- **Narrowed `Account.status`.** Was `string`; now `AccountStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'pending'`. Mirrors `normalizeCompanyStatus` on the server. **Forward-compat:** unknown values collapse to `'active'` rather than throw, so partners never see a runtime crash from a server-side enum addition. +- **Tightened resource methods.** `assessments`, `challenges`, `candidates`, `sessions`, and `account` now type their HTTP responses with `WireListResponse>` / `Wire` instead of `any`. +- **Dropped the catch-all index signature on `SessionInsights`.** Was `[key: string]: unknown`, which silently widened the typed `aiUsagePercent` and `testPassRate` fields back to `unknown` and defeated narrowing. The SessionInsights interface now exposes only the three documented fields (`aiUsagePercent`, `testPassRate`, `codeQuality`); the server doesn't emit additional keys today, so dropping the index sig has no observed runtime impact. + +### Subtle behavior changes — partner action may be required +- **`account.billingCycle` is no longer assignable to arbitrary `string`.** Partners holding the value in a wider variable will need to widen explicitly (`as string`) or, preferably, narrow on the union. If you persisted custom non-canonical billing cycles via SDK before this release, they now appear as `null` rather than the original string. +- **`account.status` is no longer assignable to arbitrary `string`.** Same treatment: partners storing the raw status as a wider type will need an explicit cast. Unknown server statuses now read as `'active'` rather than the raw value. +- **`session.insights[someExtraKey]`** no longer compiles. The catch-all index signature was the only thing letting partners reach in for undocumented keys; if you were doing this, surface the field on the server side or stop relying on it. ### Documentation - **Broaden audience framing.** README intro now positions the SDK for any hiring workflow integration — not specifically ATS partners. diff --git a/package.json b/package.json index 0487d21..0449ca7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datacline/langos-sdk-node", - "version": "0.2.0-alpha.2", + "version": "0.2.0-alpha.3", "description": "Official Node.js / TypeScript SDK for the Langos API.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/client.ts b/src/client.ts index 6bdf988..720fbd6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,6 +5,7 @@ import { ChallengesResource } from './resources/challenges.js'; import { SessionsResource } from './resources/sessions.js'; import { Webhooks } from './resources/webhooks.js'; import { noopLogger } from './core/logger.js'; +import { DEFAULT_MAX_RETRY_AFTER_MS } from './core/retry.js'; import type { ResolvedClientConfig } from './core/request.js'; import type { LangosOptions } from './types.js'; @@ -56,6 +57,7 @@ export class Langos { baseUrl: (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''), timeout: options.timeout ?? DEFAULT_TIMEOUT_MS, maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, + maxRetryAfterMs: options.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS, fetchImpl, logger: options.logger ?? noopLogger, appName: options.appName, diff --git a/src/core/errors.ts b/src/core/errors.ts index 7e19598..cb8599e 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -208,6 +208,34 @@ export class LangosTimeoutError extends LangosError { } } +/** Thrown when a partner-supplied AbortSignal cancels the request. */ +export class LangosAbortError extends LangosError { + readonly cause: unknown; + constructor(cause: unknown) { + super('Request aborted by caller'); + this.name = 'LangosAbortError'; + this.cause = cause; + } +} + +/** + * Thrown when a 2xx response body cannot be parsed as JSON. Distinguishes + * "server returned the wrong content-type" (e.g. HTML from a SPA fallback) from + * an empty/no-content 204. + */ +export class LangosResponseFormatError extends LangosError { + readonly contentType: string | null; + readonly bodyPreview: string; + constructor(contentType: string | null, bodyPreview: string) { + super( + `Expected JSON response, got ${contentType ?? 'unknown content-type'}: ${bodyPreview.slice(0, 120)}`, + ); + this.name = 'LangosResponseFormatError'; + this.contentType = contentType; + this.bodyPreview = bodyPreview; + } +} + /** Thrown by `Langos.webhooks.constructEvent` when signature verification fails. */ export class LangosSignatureVerificationError extends LangosError { constructor(reason: string) { @@ -215,3 +243,18 @@ export class LangosSignatureVerificationError extends LangosError { this.name = 'LangosSignatureVerificationError'; } } + +/** + * Thrown by `Langos.webhooks.constructEvent` when the webhook signature + * verifies successfully but the body cannot be parsed as JSON. Distinct from + * {@link LangosSignatureVerificationError} because the failure modes have + * different operational responses: signature failure suggests forgery / config + * drift (rotate secret, alert), payload failure suggests a producer bug or a + * proxy that mutated the body (file an upstream ticket, do NOT rotate). + */ +export class LangosWebhookPayloadError extends LangosError { + constructor(reason: string) { + super(`Webhook payload could not be parsed: ${reason}`); + this.name = 'LangosWebhookPayloadError'; + } +} diff --git a/src/core/request.ts b/src/core/request.ts index a60c4b4..ac56b4b 100644 --- a/src/core/request.ts +++ b/src/core/request.ts @@ -1,6 +1,8 @@ import { LangosAPIError, + LangosAbortError, LangosConnectionError, + LangosResponseFormatError, LangosTimeoutError, } from './errors.js'; import { shouldRetry, backoffDelay, sleep } from './retry.js'; @@ -14,6 +16,7 @@ export interface ResolvedClientConfig { baseUrl: string; timeout: number; maxRetries: number; + maxRetryAfterMs: number; fetchImpl: typeof fetch; logger: Logger; appName: string | undefined; @@ -78,7 +81,7 @@ export async function makeRequest( ac.signal.aborted && !userSignal?.aborted && (networkError as { name?: string })?.name === 'AbortError'; - if (userSignal?.aborted) throw networkError; + if (userSignal?.aborted) throw new LangosAbortError(networkError); if (attempt < userMaxRetries && shouldRetry(null, true)) { const delay = backoffDelay(attempt); cfg.logger.debug({ delay, err: String(networkError) }, 'langos retry (network)'); @@ -105,6 +108,7 @@ export async function makeRequest( const delay = backoffDelay( attempt, Number.isFinite(retryAfter) ? (retryAfter as number) : undefined, + cfg.maxRetryAfterMs, ); cfg.logger.debug({ delay, status }, 'langos retry (status)'); await sleep(delay); @@ -118,6 +122,15 @@ export async function makeRequest( if (status === 204) return undefined as unknown as T; + // Defensive: a 2xx response from the wrong server (e.g. SPA fallback at the + // wrong baseUrl) is HTML with content-type text/html. Catch this loud here + // rather than silently casting garbage downstream. + const ctype = response!.headers.get('content-type'); + if (ctype && !/^application\/(json|problem\+json)/i.test(ctype)) { + const text = await response!.text(); + throw new LangosResponseFormatError(ctype, text); + } + const data = await safeReadJson(response!); return data as T; } diff --git a/src/core/retry.ts b/src/core/retry.ts index 585a14a..4837f44 100644 --- a/src/core/retry.ts +++ b/src/core/retry.ts @@ -2,6 +2,13 @@ const INITIAL_MS = 500; const MAX_MS = 8_000; +// Default ceiling on Retry-After. Real-world rate-limit windows commonly run +// 60–300s; capping below that defeats the header (we'd retry early, eat the +// 429 again, and burn the partner's budget). 5 minutes is a sensible upper +// bound — anything higher and partners almost always want to surface the +// error to their caller rather than block the request that long. Configurable +// per-client via `LangosOptions.maxRetryAfterMs`. +export const DEFAULT_MAX_RETRY_AFTER_MS = 300_000; export function shouldRetry(status: number | null, isNetworkError: boolean): boolean { if (isNetworkError) return true; @@ -16,9 +23,42 @@ export function shouldRetry(status: number | null, isNetworkError: boolean): boo return false; } -export function backoffDelay(attempt: number, retryAfterSeconds?: number): number { - if (retryAfterSeconds && retryAfterSeconds > 0) { - return Math.min(retryAfterSeconds * 1000, MAX_MS * 4); +/** + * Compute the delay before the next retry attempt. + * + * Honors `Retry-After` (in seconds) when the server emits a sane value: + * - finite, non-negative + * - <= `maxRetryAfterMs` once converted to ms + * + * Anything else (NaN, negative, absurdly large, malformed HTTP-date) is + * ignored and we fall back to exponential-with-jitter. The fallback is the + * safer default — a hostile or buggy server can't trick the SDK into + * sleeping for hours by emitting `Retry-After: 99999999`. + * + * NOTE: HTTP-date Retry-After (RFC 7231) is NOT parsed here. The server-side + * rate-limit middleware emits delta-seconds and that is the only format we + * commit to honoring. If we ever start receiving HTTP-date from an upstream + * proxy, the value will fail the finiteness check and we'll fall through to + * backoff — which is correct, just not optimal. + */ +export function backoffDelay( + attempt: number, + retryAfterSeconds?: number, + maxRetryAfterMs: number = DEFAULT_MAX_RETRY_AFTER_MS, +): number { + if ( + retryAfterSeconds !== undefined && + Number.isFinite(retryAfterSeconds) && + retryAfterSeconds >= 0 + ) { + const requestedMs = retryAfterSeconds * 1000; + if (requestedMs <= maxRetryAfterMs) { + return requestedMs; + } + // Server asked for a wait longer than our cap — fall through to backoff + // rather than silently honoring an attacker-controlled "sleep for an + // hour" value. Returning the cap would also be defensible, but the + // partner is better served by a fast retry + a 429 they can surface. } const base = Math.min(INITIAL_MS * Math.pow(2, attempt), MAX_MS); return Math.floor(base * (0.5 + Math.random() * 0.5)); diff --git a/src/core/transform.ts b/src/core/transform.ts index dfa286f..32c0c71 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -1,14 +1,36 @@ // Snake_case <-> camelCase mapping, applied per-resource (explicit, not a deep // generic walk). Keeps `metadata` and similar opaque blobs un-touched. +// +// Wire-side interfaces are declared INSIDE this file (not exported from +// `src/types.ts`) because they're an implementation detail of the SDK's HTTP +// boundary. Partner code should never reach in and depend on the snake_case +// shapes — anything reusable is normalized into the camelCase types in +// `src/types.ts`. +// +// Conventions: +// - Optional + nullable fields on the wire are typed as `T | null | undefined` +// so we can mirror "key omitted" vs "key present but null" without +// `any`-casts. Both collapse to `null` in the SDK-facing type. +// - `metadata`-style opaque blobs use `Record` (or the +// stricter type if known); we never recurse into them. +// - Enum-shaped strings (status, billing_cycle, plan_tier) are typed as +// `string` on the wire and narrowed by a dedicated normalizer with a +// deploy-skew fallback. Server-side enum additions never throw the SDK. import type { Account, + AccountStatus, Assessment, + BillingCycle, Candidate, + CandidateStatus, Challenge, ChallengeStatus, + PlanCaps, Session, SessionAnalytics, + SessionInsights, + SessionStatus, SessionSubmission, SessionFeedback, CandidateCreateParams, @@ -16,9 +38,179 @@ import type { WebhookEndpointParams, } from '../types.js'; -/* ------------------------- assessment ------------------------- */ +/* ============================================================== * + * Wire-side interfaces * + * ============================================================== */ -export function assessmentFromWire(w: any): Assessment { +/** + * Wire envelope for a single `Assessment` row from `/v1/assessments[/:id]`. + * Any field absent from the response collapses to `null` on the SDK side + * (see `assessmentFromWire`). + */ +export interface WireAssessment { + id: string; + object?: 'assessment'; + name: string; + description?: string | null; + challenge_count?: number | null; + created_at: string; + updated_at: string; +} + +export interface WireChallenge { + id: string; + object?: 'challenge'; + title: string; + description?: string | null; + language: string; + difficulty?: string | null; + category?: string | null; + time_limit_minutes?: number | null; + status: string; + created_at: string; +} + +export interface WireCandidate { + id: string; + object?: 'candidate'; + email: string; + name?: string | null; + assessment_id: string; + external_id?: string | null; + status: string; + invitation_url?: string | null; + invited_at?: string | null; + started_at?: string | null; + completed_at?: string | null; + cancelled_at?: string | null; + expires_at?: string | null; + latest_session_id?: string | null; + score?: number | string | null; + metadata?: Record | null; + created_at: string; + updated_at: string; +} + +export interface WireSessionSubmission { + language?: string | null; + final_code?: string | null; + diff?: string | null; + commit_sha?: string | null; + preview_url?: string | null; +} + +export interface WireSessionFeedback { + notes?: string | null; + problem_solving_score?: number | string | null; + code_quality_score?: number | string | null; +} + +export interface WireSessionAnalytics { + editor_event_count?: number | null; + run_event_count?: number | null; + test_event_count?: number | null; + ai_event_count?: number | null; + paste_event_count?: number | null; + active_seconds?: number | null; + idle_seconds?: number | null; + coding_seconds?: number | null; +} + +export interface WireSessionInsights { + ai_usage_percent?: number | string | null; + test_pass_rate?: number | string | null; + code_quality?: Record | null; +} + +export interface WireSession { + id: string; + object?: 'session'; + candidate_id: string; + assessment_id: string; + status: string; + attempt?: number | null; + started_at?: string | null; + completed_at?: string | null; + duration_seconds?: number | null; + score?: number | string | null; + passed?: boolean | null; + report_url?: string | null; + submission?: WireSessionSubmission | null; + analytics?: WireSessionAnalytics | null; + insights?: WireSessionInsights | null; + feedback?: WireSessionFeedback | null; + created_at: string; + updated_at: string; +} + +export interface WirePlanCaps { + label?: string | null; + price_monthly?: number | string | null; + session_limit?: number | null; + ready_made_challenges?: number | null; + custom_challenges_max?: number | null; + features?: Record | null; + support?: string | null; + popular?: boolean | null; + contact_sales?: boolean | null; +} + +export interface WireAccountIntegration { + provider?: string | null; + api_key_prefix?: string | null; + scopes?: unknown; + rate_limit_per_minute?: number | null; + webhook_url?: string | null; +} + +export interface WireAccount { + id: string; + object?: 'account'; + name: string; + slug?: string | null; + plan_tier?: string | null; + billing_cycle?: string | null; + status?: string | null; + sessions_used?: number | null; + sessions_limit?: number | null; + sessions_remaining?: number | null; + trial_ends_at?: string | null; + features?: { + web_ide_enabled?: boolean | null; + replay_enabled?: boolean | null; + ai_assistance_enabled?: boolean | null; + } | null; + plan_caps?: WirePlanCaps | null; + integration?: WireAccountIntegration | null; +} + +export interface WireWebhookEndpoint { + object?: 'webhook_endpoint'; + webhook_url?: string | null; + signing_secret?: string | null; +} + +/* ============================================================== * + * Small helpers (private) * + * ============================================================== */ + +/** + * Coerce a wire numeric field that may arrive as `number`, numeric string + * (Postgres `NUMERIC` columns serialize this way through some drivers), + * `null`, or `undefined`. Returns `null` for the absent case so the SDK + * type stays `number | null` instead of `number | null | undefined`. + */ +function numOrNull(v: number | string | null | undefined): number | null { + if (v === null || v === undefined) return null; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +/* ============================================================== * + * assessment * + * ============================================================== */ + +export function assessmentFromWire(w: WireAssessment): Assessment { return { id: String(w.id), object: 'assessment', @@ -30,9 +222,21 @@ export function assessmentFromWire(w: any): Assessment { }; } -/* ------------------------- challenge ------------------------- */ +/* ============================================================== * + * challenge * + * ============================================================== */ -export function challengeFromWire(w: any): Challenge { +const CHALLENGE_STATUSES: ReadonlyArray = ['draft', 'published', 'archived']; + +function challengeStatusFromWire(raw: string): ChallengeStatus { + // Forward-compat: server may add new statuses (e.g. `'review'`). Default to + // `'draft'` so the SDK never throws and partners see a clearly inert state. + return (CHALLENGE_STATUSES as ReadonlyArray).includes(raw) + ? (raw as ChallengeStatus) + : 'draft'; +} + +export function challengeFromWire(w: WireChallenge): Challenge { return { id: String(w.id), object: 'challenge', @@ -42,14 +246,30 @@ export function challengeFromWire(w: any): Challenge { difficulty: w.difficulty ?? null, category: w.category ?? null, timeLimitMinutes: w.time_limit_minutes ?? null, - status: w.status as ChallengeStatus, + status: challengeStatusFromWire(String(w.status)), createdAt: String(w.created_at), }; } -/* ------------------------- candidate ------------------------- */ +/* ============================================================== * + * candidate * + * ============================================================== */ + +const CANDIDATE_STATUSES: ReadonlyArray = [ + 'invited', + 'started', + 'completed', + 'cancelled', + 'error', +]; -export function candidateFromWire(w: any): Candidate { +function candidateStatusFromWire(raw: string): CandidateStatus { + return (CANDIDATE_STATUSES as ReadonlyArray).includes(raw) + ? (raw as CandidateStatus) + : 'error'; +} + +export function candidateFromWire(w: WireCandidate): Candidate { return { id: String(w.id), object: 'candidate', @@ -57,7 +277,7 @@ export function candidateFromWire(w: any): Candidate { name: w.name ?? null, assessmentId: String(w.assessment_id), externalId: w.external_id ?? null, - status: w.status, + status: candidateStatusFromWire(String(w.status)), invitationUrl: w.invitation_url ?? null, invitedAt: w.invited_at ?? null, startedAt: w.started_at ?? null, @@ -65,7 +285,7 @@ export function candidateFromWire(w: any): Candidate { cancelledAt: w.cancelled_at ?? null, expiresAt: w.expires_at ?? null, latestSessionId: w.latest_session_id ?? null, - score: w.score === null || w.score === undefined ? null : Number(w.score), + score: numOrNull(w.score), metadata: w.metadata ?? null, createdAt: String(w.created_at), updatedAt: String(w.updated_at), @@ -85,9 +305,30 @@ export function candidateCreateToWire(p: CandidateCreateParams): Record = [ + 'pending', + 'provisioning', + 'active', + 'submitted', + 'completed', + 'failed', + 'expired', +]; + +function sessionStatusFromWire(raw: string): SessionStatus { + // Forward-compat: unknown statuses fall back to `'pending'` (the most + // benign state — partners won't trigger "completed" handlers on an + // unrecognized future status). + return (SESSION_STATUSES as ReadonlyArray).includes(raw) + ? (raw as SessionStatus) + : 'pending'; +} -function submissionFromWire(w: any): SessionSubmission | null { +function submissionFromWire(w: WireSessionSubmission | null | undefined): SessionSubmission | null { if (!w) return null; return { language: w.language ?? null, @@ -98,23 +339,17 @@ function submissionFromWire(w: any): SessionSubmission | null { }; } -function feedbackFromWire(w: any): SessionFeedback | null { +function feedbackFromWire(w: WireSessionFeedback | null | undefined): SessionFeedback | null { if (!w) return null; return { notes: w.notes ?? null, - problemSolvingScore: - w.problem_solving_score === null || w.problem_solving_score === undefined - ? null - : Number(w.problem_solving_score), - codeQualityScore: - w.code_quality_score === null || w.code_quality_score === undefined - ? null - : Number(w.code_quality_score), + problemSolvingScore: numOrNull(w.problem_solving_score), + codeQualityScore: numOrNull(w.code_quality_score), }; } -function analyticsFromWire(w: any): SessionAnalytics { - const a = w || {}; +function analyticsFromWire(w: WireSessionAnalytics | null | undefined): SessionAnalytics { + const a = w ?? {}; return { editorEventCount: Number(a.editor_event_count ?? 0), runEventCount: Number(a.run_event_count ?? 0), @@ -127,18 +362,27 @@ function analyticsFromWire(w: any): SessionAnalytics { }; } -export function sessionFromWire(w: any): Session { +function insightsFromWire(w: WireSessionInsights | null | undefined): SessionInsights | null { + if (!w) return null; + return { + aiUsagePercent: numOrNull(w.ai_usage_percent), + testPassRate: numOrNull(w.test_pass_rate), + codeQuality: w.code_quality ?? null, + }; +} + +export function sessionFromWire(w: WireSession): Session { return { id: String(w.id), object: 'session', candidateId: String(w.candidate_id), assessmentId: String(w.assessment_id), - status: w.status, + status: sessionStatusFromWire(String(w.status)), attempt: Number(w.attempt ?? 1), startedAt: w.started_at ?? null, completedAt: w.completed_at ?? null, durationSeconds: w.duration_seconds ?? null, - score: w.score === null || w.score === undefined ? null : Number(w.score), + score: numOrNull(w.score), passed: typeof w.passed === 'boolean' ? w.passed : null, reportUrl: w.report_url ?? null, submission: submissionFromWire(w.submission), @@ -150,7 +394,9 @@ export function sessionFromWire(w: any): Session { }; } -/* ------------------------- account ------------------------- */ +/* ============================================================== * + * account * + * ============================================================== */ import type { PlanTier } from '../types.js'; @@ -168,74 +414,92 @@ function planTierFromWire(raw: unknown): PlanTier { : 'free'; } -export function accountFromWire(w: any): Account { +const BILLING_CYCLES: ReadonlyArray = ['monthly', 'yearly']; + +/** + * Normalize `billing_cycle`. Returns `null` for `null`/`undefined`/empty (no + * Stripe subscription on file) AND for any unknown future value — partners + * never see a thrown error from a forward-compatible server addition. + */ +function billingCycleFromWire(raw: unknown): BillingCycle | null { + if (typeof raw !== 'string' || raw.length === 0) return null; + return (BILLING_CYCLES as ReadonlyArray).includes(raw) + ? (raw as BillingCycle) + : null; +} + +const ACCOUNT_STATUSES: ReadonlyArray = [ + 'trialing', + 'active', + 'past_due', + 'canceled', + 'pending', +]; + +/** + * Normalize `status`. Falls back to `'active'` for unknown values rather + * than throwing — `'active'` is the most permissive default (partners won't + * accidentally render "subscription past due" warnings for an enum value the + * SDK doesn't know yet). + */ +function accountStatusFromWire(raw: unknown): AccountStatus { + return typeof raw === 'string' && (ACCOUNT_STATUSES as ReadonlyArray).includes(raw) + ? (raw as AccountStatus) + : 'active'; +} + +function planCapsFromWire(w: WirePlanCaps | null | undefined): PlanCaps | null { + if (!w) return null; + return { + label: String(w.label ?? ''), + priceMonthly: numOrNull(w.price_monthly), + billing: 'monthly', + currency: 'USD', + sessionLimit: w.session_limit ?? null, + readyMadeChallenges: w.ready_made_challenges ?? null, + customChallengesMax: w.custom_challenges_max ?? null, + features: w.features ?? {}, + support: String(w.support ?? ''), + popular: w.popular === true, + contactSales: w.contact_sales === true, + }; +} + +export function accountFromWire(w: WireAccount): Account { + const integ = w.integration ?? {}; return { id: String(w.id), object: 'account', name: String(w.name), slug: String(w.slug ?? ''), planTier: planTierFromWire(w.plan_tier), - billingCycle: String(w.billing_cycle), - status: String(w.status), + billingCycle: billingCycleFromWire(w.billing_cycle), + status: accountStatusFromWire(w.status), sessionsUsed: Number(w.sessions_used ?? 0), - sessionsLimit: - w.sessions_limit === null || w.sessions_limit === undefined - ? null - : Number(w.sessions_limit), - sessionsRemaining: - w.sessions_remaining === null || w.sessions_remaining === undefined - ? null - : Number(w.sessions_remaining), + sessionsLimit: w.sessions_limit ?? null, + sessionsRemaining: w.sessions_remaining ?? null, trialEndsAt: w.trial_ends_at ?? null, features: { webIdeEnabled: !!w.features?.web_ide_enabled, replayEnabled: !!w.features?.replay_enabled, aiAssistanceEnabled: !!w.features?.ai_assistance_enabled, }, - planCaps: w.plan_caps - ? { - label: String(w.plan_caps.label ?? ''), - priceMonthly: - w.plan_caps.price_monthly === null || w.plan_caps.price_monthly === undefined - ? null - : Number(w.plan_caps.price_monthly), - billing: 'monthly', - currency: 'USD', - sessionLimit: - w.plan_caps.session_limit === null || w.plan_caps.session_limit === undefined - ? null - : Number(w.plan_caps.session_limit), - readyMadeChallenges: - w.plan_caps.ready_made_challenges === null || w.plan_caps.ready_made_challenges === undefined - ? null - : Number(w.plan_caps.ready_made_challenges), - customChallengesMax: - w.plan_caps.custom_challenges_max === null || w.plan_caps.custom_challenges_max === undefined - ? null - : Number(w.plan_caps.custom_challenges_max), - features: w.plan_caps.features ?? {}, - support: String(w.plan_caps.support ?? ''), - popular: w.plan_caps.popular === true, - contactSales: w.plan_caps.contact_sales === true, - } - : null, + planCaps: planCapsFromWire(w.plan_caps), integration: { - provider: String(w.integration?.provider ?? ''), - apiKeyPrefix: String(w.integration?.api_key_prefix ?? ''), - scopes: Array.isArray(w.integration?.scopes) ? w.integration.scopes.map(String) : [], - rateLimitPerMinute: - w.integration?.rate_limit_per_minute === null || - w.integration?.rate_limit_per_minute === undefined - ? null - : Number(w.integration.rate_limit_per_minute), - webhookUrl: w.integration?.webhook_url ?? null, + provider: String(integ.provider ?? ''), + apiKeyPrefix: String(integ.api_key_prefix ?? ''), + scopes: Array.isArray(integ.scopes) ? integ.scopes.map(String) : [], + rateLimitPerMinute: integ.rate_limit_per_minute ?? null, + webhookUrl: integ.webhook_url ?? null, }, }; } -/* ------------------------- webhook endpoint ------------------------- */ +/* ============================================================== * + * webhook endpoint * + * ============================================================== */ -export function webhookEndpointFromWire(w: any): WebhookEndpoint { +export function webhookEndpointFromWire(w: WireWebhookEndpoint): WebhookEndpoint { return { object: 'webhook_endpoint', webhookUrl: w.webhook_url ?? null, @@ -249,27 +513,3 @@ export function webhookEndpointParamsToWire(p: WebhookEndpointParams): Record, known: string[]) { - const out: Record = {}; - for (const [k, v] of Object.entries(o)) { - if (!known.includes(k)) out[k] = v; - } - return out; -} diff --git a/src/index.ts b/src/index.ts index 27de4da..eed4fa5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,10 @@ export { LangosServerError, LangosConnectionError, LangosTimeoutError, + LangosAbortError, + LangosResponseFormatError, LangosSignatureVerificationError, + LangosWebhookPayloadError, } from './core/errors.js'; export type { LangosErrorBody, @@ -22,6 +25,8 @@ export type { Account, AccountFeatures, AccountIntegration, + AccountStatus, + BillingCycle, PlanCaps, PlanTier, Assessment, diff --git a/src/resources/account.ts b/src/resources/account.ts index 5b0e970..47baea6 100644 --- a/src/resources/account.ts +++ b/src/resources/account.ts @@ -4,6 +4,7 @@ import { webhookEndpointFromWire, webhookEndpointParamsToWire, } from '../core/transform.js'; +import type { WireAccount, WireWebhookEndpoint } from '../core/transform.js'; import type { Account, RequestOptions, @@ -39,7 +40,7 @@ export class AccountResource extends APIResource { * @returns {Promise} */ async retrieve(options?: RequestOptions): Promise { - const w = await this.get('/account', undefined, options); + const w = await this.get('/account', undefined, options); return accountFromWire(w); } @@ -58,7 +59,7 @@ export class AccountResource extends APIResource { options?: RequestOptions, ): Promise { const body = webhookEndpointParamsToWire(params); - const w = await this.patch('/account/webhook-endpoint', body, options); + const w = await this.patch('/account/webhook-endpoint', body, options); return webhookEndpointFromWire(w); } } diff --git a/src/resources/assessments.ts b/src/resources/assessments.ts index c7caaa1..cdc0227 100644 --- a/src/resources/assessments.ts +++ b/src/resources/assessments.ts @@ -1,6 +1,7 @@ import { APIResource } from './base.js'; import { fetchPage } from '../core/pagination.js'; import { assessmentFromWire } from '../core/transform.js'; +import type { WireAssessment } from '../core/transform.js'; import type { Assessment, AssessmentListParams, @@ -8,6 +9,13 @@ import type { RequestOptions, } from '../types.js'; +interface WireListResponse { + object: 'list'; + data: T[]; + has_more: boolean; + next_cursor: string | null; +} + export class AssessmentsResource extends APIResource { list( params: AssessmentListParams = {}, @@ -15,7 +23,7 @@ export class AssessmentsResource extends APIResource { ): Promise> { return fetchPage( cursor => - this.get<{ object: 'list'; data: any[]; has_more: boolean; next_cursor: string | null }>( + this.get>( '/assessments', { limit: params.limit, cursor: cursor ?? params.cursor }, options, @@ -25,7 +33,11 @@ export class AssessmentsResource extends APIResource { } async retrieve(id: string, options?: RequestOptions): Promise { - const w = await this.get(`/assessments/${encodeURIComponent(id)}`, undefined, options); + const w = await this.get( + `/assessments/${encodeURIComponent(id)}`, + undefined, + options, + ); return assessmentFromWire(w); } } diff --git a/src/resources/candidates.ts b/src/resources/candidates.ts index 0708f06..e1c0760 100644 --- a/src/resources/candidates.ts +++ b/src/resources/candidates.ts @@ -1,6 +1,7 @@ import { APIResource } from './base.js'; import { fetchPage } from '../core/pagination.js'; import { candidateCreateToWire, candidateFromWire } from '../core/transform.js'; +import type { WireCandidate } from '../core/transform.js'; import type { AsyncIterablePage, Candidate, @@ -9,6 +10,13 @@ import type { RequestOptions, } from '../types.js'; +interface WireListResponse { + object: 'list'; + data: T[]; + has_more: boolean; + next_cursor: string | null; +} + export class CandidatesResource extends APIResource { list( params: CandidateListParams = {}, @@ -16,29 +24,44 @@ export class CandidatesResource extends APIResource { ): Promise> { return fetchPage( cursor => - this.get('/candidates', { - limit: params.limit, - cursor: cursor ?? params.cursor, - status: params.status, - assessment_id: params.assessmentId, - }, options), + this.get>( + '/candidates', + { + limit: params.limit, + cursor: cursor ?? params.cursor, + status: params.status, + assessment_id: params.assessmentId, + }, + options, + ), candidateFromWire, ); } async retrieve(id: string, options?: RequestOptions): Promise { - const w = await this.get(`/candidates/${encodeURIComponent(id)}`, undefined, options); + const w = await this.get( + `/candidates/${encodeURIComponent(id)}`, + undefined, + options, + ); return candidateFromWire(w); } async create(params: CandidateCreateParams, options?: RequestOptions): Promise { - const w = await this.post('/candidates', candidateCreateToWire(params), options); + const w = await this.post( + '/candidates', + candidateCreateToWire(params), + options, + ); return candidateFromWire(w); } /** Idempotent — calling twice on a cancelled candidate returns the same record. */ async cancel(id: string, options?: RequestOptions): Promise { - const w = await this.delete(`/candidates/${encodeURIComponent(id)}`, options); + const w = await this.delete( + `/candidates/${encodeURIComponent(id)}`, + options, + ); return candidateFromWire(w); } } diff --git a/src/resources/challenges.ts b/src/resources/challenges.ts index d0a2bb1..60205ba 100644 --- a/src/resources/challenges.ts +++ b/src/resources/challenges.ts @@ -1,6 +1,7 @@ import { APIResource } from './base.js'; import { fetchPage } from '../core/pagination.js'; import { challengeFromWire } from '../core/transform.js'; +import type { WireChallenge } from '../core/transform.js'; import type { Challenge, ChallengeListParams, @@ -8,6 +9,13 @@ import type { RequestOptions, } from '../types.js'; +interface WireListResponse { + object: 'list'; + data: T[]; + has_more: boolean; + next_cursor: string | null; +} + export class ChallengesResource extends APIResource { list( params: ChallengeListParams = {}, @@ -15,7 +23,7 @@ export class ChallengesResource extends APIResource { ): Promise> { return fetchPage( cursor => - this.get<{ object: 'list'; data: any[]; has_more: boolean; next_cursor: string | null }>( + this.get>( '/challenges', { limit: params.limit, @@ -30,7 +38,11 @@ export class ChallengesResource extends APIResource { } async retrieve(id: string, options?: RequestOptions): Promise { - const w = await this.get(`/challenges/${encodeURIComponent(id)}`, undefined, options); + const w = await this.get( + `/challenges/${encodeURIComponent(id)}`, + undefined, + options, + ); return challengeFromWire(w); } } diff --git a/src/resources/sessions.ts b/src/resources/sessions.ts index fa9b024..5b310fa 100644 --- a/src/resources/sessions.ts +++ b/src/resources/sessions.ts @@ -1,6 +1,7 @@ import { APIResource } from './base.js'; import { fetchPage } from '../core/pagination.js'; import { sessionFromWire } from '../core/transform.js'; +import type { WireSession } from '../core/transform.js'; import type { AsyncIterablePage, RequestOptions, @@ -8,9 +9,20 @@ import type { SessionListParams, } from '../types.js'; +interface WireListResponse { + object: 'list'; + data: T[]; + has_more: boolean; + next_cursor: string | null; +} + export class SessionsResource extends APIResource { async retrieve(id: string, options?: RequestOptions): Promise { - const w = await this.get(`/sessions/${encodeURIComponent(id)}`, undefined, options); + const w = await this.get( + `/sessions/${encodeURIComponent(id)}`, + undefined, + options, + ); return sessionFromWire(w); } @@ -21,7 +33,7 @@ export class SessionsResource extends APIResource { ): Promise> { return fetchPage( cursor => - this.get( + this.get>( `/candidates/${encodeURIComponent(candidateId)}/sessions`, { limit: params.limit, cursor: cursor ?? params.cursor }, options, diff --git a/src/resources/webhooks.ts b/src/resources/webhooks.ts index 55d4cad..d262d9f 100644 --- a/src/resources/webhooks.ts +++ b/src/resources/webhooks.ts @@ -1,5 +1,8 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; -import { LangosSignatureVerificationError } from '../core/errors.js'; +import { + LangosSignatureVerificationError, + LangosWebhookPayloadError, +} from '../core/errors.js'; import type { WebhookEvent } from '../types.js'; const DEFAULT_TOLERANCE_SECONDS = 300; @@ -14,6 +17,12 @@ const MIN_SECRET_LENGTH = 16; // boundary). A negative or zero `t=` in the signature header is also a tampered // header — real servers always emit a positive epoch second. const MAX_TIMESTAMP_SECONDS = 2 ** 32; +// Hard cap on the signature header length we'll parse. A real `Langos-Signature` +// is roughly `t=<10 digits>,v1=<64 hex>` — well under 200 chars even with a +// few rotated keys. Refuse to parse anything beyond 4 KiB so an attacker +// can't waste CPU on `,`-splitting an attacker-controlled megabyte string +// before we even reach HMAC. +const MAX_SIGNATURE_HEADER_LENGTH = 4096; /** * Webhook helpers. v1.0 ships verification only — outbound delivery from Langos @@ -43,7 +52,30 @@ export const Webhooks = { if (!signatureHeader) { throw new LangosSignatureVerificationError('Missing Langos-Signature header'); } - const sigHeader = Array.isArray(signatureHeader) ? signatureHeader[0]! : signatureHeader; + // Some proxies / Node frameworks expose duplicate `Langos-Signature` + // headers as a string[] (e.g. raw `IncomingMessage.headers` for + // set-cookie-style multi-value semantics). Joining with `,` is safe + // because `parseSignatureHeader` already splits on `,` — every `t=` and + // `v1=` entry from every copy of the header gets considered, and the + // mismatch tolerance is just "no signature matched". Picking + // `signatureHeader[0]` (the previous behavior) silently dropped the + // signatures emitted by other copies of the header, which broke key + // rotation when a proxy split versus joined the values inconsistently. + let sigHeader: string; + if (Array.isArray(signatureHeader)) { + if (signatureHeader.length === 0) { + throw new LangosSignatureVerificationError('Missing Langos-Signature header'); + } + sigHeader = signatureHeader.join(','); + } else { + sigHeader = signatureHeader; + } + + if (sigHeader.length > MAX_SIGNATURE_HEADER_LENGTH) { + throw new LangosSignatureVerificationError( + `Signature header exceeds ${MAX_SIGNATURE_HEADER_LENGTH} chars (refusing to parse attacker-controlled oversize input)`, + ); + } const parsed = parseSignatureHeader(sigHeader); if (parsed.timestamp === null || parsed.signatures.length === 0) { @@ -66,11 +98,18 @@ export const Webhooks = { throw new LangosSignatureVerificationError('No signatures matched'); } + // Signature has verified at this point. A JSON-parse failure here is a + // *payload* problem, not a signature problem — different operational + // response (file an upstream/producer ticket; do NOT rotate the secret). + // Throw a distinct error so partner handlers can branch on the + // remediation, not just log "verification failed" for both. let event: WebhookEvent; try { event = JSON.parse(rawBody) as WebhookEvent; - } catch { - throw new LangosSignatureVerificationError('Payload is not valid JSON'); + } catch (err) { + throw new LangosWebhookPayloadError( + `signature OK but body is not valid JSON: ${(err as Error).message}`, + ); } return event; diff --git a/src/types.ts b/src/types.ts index 85e5334..d7153ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -70,7 +70,6 @@ export interface SessionInsights { aiUsagePercent: number | null; testPassRate: number | null; codeQuality: Record | null; - [key: string]: unknown; } export interface SessionFeedback { @@ -134,6 +133,39 @@ export interface Session { */ export type PlanTier = 'free' | 'starter' | 'mid_tier' | 'growth' | 'custom'; +/** + * Stripe-aligned subscription cycle the company is billed on. + * + * - `'monthly'` — standard month-to-month plan. + * - `'yearly'` — annual subscription (Stripe `interval=year`). + * - `null` — no Stripe subscription on file (free tier, internal + * accounts, or pre-billing legacy rows). + * + * The server may add new cycles in the future (e.g. `'quarterly'`); when that + * happens an older SDK falls back to `null` rather than throwing, so partner + * code keeps working until they upgrade. See `billingCycleFromWire` in + * `core/transform.ts`. + */ +export type BillingCycle = 'monthly' | 'yearly'; + +/** + * Lifecycle state for the company's billing relationship. + * + * - `'trialing'` — within the initial trial window (`trial_ends_at` set). + * - `'active'` — paid subscription in good standing, OR a free-tier + * account post-trial (free tier persists as `'active'`). + * - `'past_due'` — Stripe reported a payment failure; partners should treat + * this as "service is degraded / will be cut off soon". + * - `'canceled'` — subscription explicitly cancelled (Stripe spelling, one + * `l`). + * - `'pending'` — incomplete signup or payment-method confirmation pending. + * + * If the server starts emitting a status the SDK doesn't recognize, the + * transform falls back to `'active'` rather than throwing — consumers should + * never see a runtime error from a forward-compatible enum addition. + */ +export type AccountStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'pending'; + /** * Account info for the Langos company that owns the calling integration. * Returned by `client.account.retrieve()`. @@ -154,8 +186,17 @@ export interface Account { * tier strings. */ planTier: PlanTier; - billingCycle: string; - status: string; + /** + * Stripe-aligned billing cadence; `null` for accounts without a Stripe + * subscription (free tier, internal). Narrower than the legacy `string` + * type — see {@link BillingCycle}. + */ + billingCycle: BillingCycle | null; + /** + * Subscription lifecycle. Narrower than the legacy `string` type — see + * {@link AccountStatus}. + */ + status: AccountStatus; sessionsUsed: number; /** `null` on unlimited plans (e.g. `custom`). */ sessionsLimit: number | null; @@ -317,6 +358,14 @@ export interface LangosOptions { baseUrl?: string; timeout?: number; maxRetries?: number; + /** + * Upper bound on how long the SDK will wait when honoring a `Retry-After` + * header (in milliseconds). Defaults to 300_000 (5 minutes). Server values + * larger than this fall back to exponential backoff with jitter rather than + * blocking the request for the requested duration. Set lower if your + * integration prefers fast-failing over patient retries. + */ + maxRetryAfterMs?: number; fetch?: typeof fetch; logger?: Logger; /** Free-form identifier appended to the User-Agent (e.g. "GreenhouseConnector/2.1.0"). */ diff --git a/test/unit/failure-modes.test.ts b/test/unit/failure-modes.test.ts new file mode 100644 index 0000000..9f180b3 --- /dev/null +++ b/test/unit/failure-modes.test.ts @@ -0,0 +1,490 @@ +/** + * Failure-mode coverage for MEDIUM-16 code-review gaps. + * + * Covers: + * - LangosTimeoutError (timeout path) + * - LangosConnectionError (DNS/refused/abort) + * - AbortSignal propagation (partner-supplied signal cancels cleanly) + * - Idempotency-Key reuse across retries (same key on every POST attempt) + * - Retry-After header parsing (numeric, HTTP-date, malformed, missing) + * - RequestOptions.signal (per-call signal) + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Langos } from '../../src/index.js'; +import { + LangosTimeoutError, + LangosConnectionError, +} from '../../src/core/errors.js'; +import { backoffDelay } from '../../src/core/retry.js'; + +const BASE = 'https://api.test.local/v1'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Client with no retries and a custom fetch. */ +function clientWith( + fetchImpl: typeof fetch, + extra?: Partial[0]>, +) { + return new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 0, + fetch: fetchImpl, + ...extra, + }); +} + +/** A custom fetch that never resolves (simulates a hung connection). */ +function hangingFetch(): Promise { + return new Promise(() => undefined); +} + +/** JSON response helper. */ +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +// --------------------------------------------------------------------------- +// LangosTimeoutError +// --------------------------------------------------------------------------- + +describe('LangosTimeoutError', () => { + it('throws LangosTimeoutError when fetch exceeds configured timeout', async () => { + // We use a custom fetch that checks the AbortSignal and rejects with + // AbortError once it fires, simulating a real timeout. + const client = clientWith((_url, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal?.aborted) { + reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); + return; + } + signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); + }); + // Never resolve — wait for the abort. + }); + }, { timeout: 50 }); + + const err = await client.assessments.list().catch((e) => e); + expect(err).toBeInstanceOf(LangosTimeoutError); + expect((err as LangosTimeoutError).timeoutMs).toBe(50); + }); + + it('LangosTimeoutError.timeoutMs reflects the configured timeout', async () => { + const client = clientWith((_url, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })); + }); + }); + }, { timeout: 120 }); + + const err = await client.assessments.list().catch((e) => e); + expect(err).toBeInstanceOf(LangosTimeoutError); + expect((err as LangosTimeoutError).timeoutMs).toBe(120); + }); + + it('LangosTimeoutError.message includes the timeout duration in ms', async () => { + const client = clientWith((_url, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })); + }); + }); + }, { timeout: 75 }); + + const err = await client.assessments.list().catch((e) => e); + expect(err).toBeInstanceOf(LangosTimeoutError); + expect((err as LangosTimeoutError).message).toContain('75'); + }); + + it('per-call timeout (RequestOptions.timeout) overrides the client-level default', async () => { + const client = new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 0, + timeout: 30_000, // generous default + fetch: (_url, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal?.aborted) { + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })); + return; + } + signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })); + }); + }); + }, + }); + + // RequestOptions are the SECOND argument; the first is list params. + const err = await client.assessments.list({}, { timeout: 60 }).catch((e) => e); + expect(err).toBeInstanceOf(LangosTimeoutError); + expect((err as LangosTimeoutError).timeoutMs).toBe(60); + }); +}); + +// --------------------------------------------------------------------------- +// LangosConnectionError +// --------------------------------------------------------------------------- + +describe('LangosConnectionError', () => { + it('throws LangosConnectionError when fetch rejects with a network error', async () => { + const dnsError = Object.assign(new TypeError('Failed to fetch'), { name: 'TypeError' }); + const client = clientWith(() => Promise.reject(dnsError)); + + const err = await client.assessments.list().catch((e) => e); + expect(err).toBeInstanceOf(LangosConnectionError); + }); + + it('LangosConnectionError.cause holds the original underlying error', async () => { + const original = new TypeError('ECONNREFUSED'); + const client = clientWith(() => Promise.reject(original)); + + const err = await client.assessments.list().catch((e) => e); + expect(err).toBeInstanceOf(LangosConnectionError); + expect((err as LangosConnectionError).cause).toBe(original); + }); + + it('LangosConnectionError.message includes the network error description', async () => { + const original = new TypeError('getaddrinfo ENOTFOUND api.test.local'); + const client = clientWith(() => Promise.reject(original)); + + const err = await client.assessments.list().catch((e) => e); + expect(err).toBeInstanceOf(LangosConnectionError); + expect((err as LangosConnectionError).message).toContain('ENOTFOUND'); + }); + + it('throws LangosConnectionError after all retries exhaust on repeated network failures', async () => { + let calls = 0; + const client = new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 2, + fetch: () => { + calls++; + return Promise.reject(new TypeError('ECONNREFUSED')); + }, + }); + + const err = await client.assessments.list().catch((e) => e); + expect(err).toBeInstanceOf(LangosConnectionError); + // 1 initial attempt + 2 retries = 3 total fetch calls. + expect(calls).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// AbortSignal propagation (partner-supplied signal) +// --------------------------------------------------------------------------- + +describe('AbortSignal propagation', () => { + /** + * Partner-supplied AbortSignal cancellations are wrapped in a typed + * LangosAbortError so callers can `instanceof`-narrow against cancellation + * vs other network failure paths. + */ + + it('throws when partner signal is pre-aborted before the call starts', async () => { + const ac = new AbortController(); + ac.abort(); + + const client = clientWith((_url, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal?.aborted) { + reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); + return; + } + signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); + }); + }); + }); + + // RequestOptions (including signal) are the SECOND argument to list(). + const err = await client.assessments.list({}, { signal: ac.signal }).catch((e) => e); + // Should reject. The exact type is a known bug (raw Error, not LangosConnectionError). + expect(err).toBeInstanceOf(Error); + // Must NOT be a timeout error — this was a user-initiated cancel. + expect(err).not.toBeInstanceOf(LangosTimeoutError); + // Documents current (buggy) behaviour: not wrapped in LangosConnectionError. + expect(err).not.toBeInstanceOf(LangosConnectionError); + }); + + it('cancels an in-flight request when partner signal fires mid-request', async () => { + const ac = new AbortController(); + + const client = clientWith((_url, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); + }); + // Trigger abort after 20ms, while the request is "in-flight". + setTimeout(() => ac.abort(), 20); + }); + }, { timeout: 30_000 }); // SDK timeout much longer than our 20ms + + // RequestOptions (including signal) are the SECOND argument to list(). + const err = await client.assessments.list({}, { signal: ac.signal }).catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(LangosTimeoutError); + }); + + it('does not retry when partner signal fires (abort is not a retryable error)', async () => { + /** + * The SDK's retry guard checks `userSignal?.aborted` before the retry + * branch (request.ts:81 fires before :82). A pre-aborted signal should + * cause immediate re-throw, not retry. + * + * BUG: The check is `userSignal?.aborted` which is evaluated AFTER the + * catch block. If the fetch hasn't yet received the abort signal at throw + * time (race), the retry branch fires. We test with a pre-aborted signal + * to ensure the deterministic path is covered. + */ + const ac = new AbortController(); + ac.abort(); + + let calls = 0; + const client = new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 3, + fetch: (_url, init) => { + calls++; + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + if (signal?.aborted) { + reject(Object.assign(new Error('AbortError'), { name: 'AbortError' })); + return; + } + signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('AbortError'), { name: 'AbortError' })); + }); + }); + }, + }); + + // RequestOptions (including signal) are the SECOND argument to list(). + await client.assessments.list({}, { signal: ac.signal }).catch(() => null); + // With a pre-aborted signal, the request should not retry at all. + expect(calls).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotency-Key reuse across retries +// --------------------------------------------------------------------------- + +describe('Idempotency-Key reuse across retries', () => { + it('sends the SAME auto-generated Idempotency-Key on every retry attempt', async () => { + let attempt = 0; + const keys: string[] = []; + + const client = new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 2, + fetch: async (_input, init) => { + attempt++; + keys.push((init?.headers as Headers).get('idempotency-key') ?? ''); + if (attempt < 3) { + return new Response('upstream error', { status: 503 }); + } + return jsonResponse({ id: 'cand_1', object: 'candidate', email: 'a@b.com', assessment_id: 'asm_1', status: 'invited', invitation_url: 'https://x', invited_at: 't', created_at: 't', updated_at: 't' }, 201); + }, + }); + + await client.candidates.create({ email: 'a@b.com', assessmentId: 'asm_1' }); + + expect(attempt).toBe(3); + expect(keys).toHaveLength(3); + // Auto-generated key must be a valid UUID. + expect(keys[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + // The SAME key must be sent on all three attempts. + expect(keys[1]).toBe(keys[0]); + expect(keys[2]).toBe(keys[0]); + }); + + it('reuses a user-supplied Idempotency-Key on every retry attempt', async () => { + let attempt = 0; + const keys: string[] = []; + + const client = new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 1, + fetch: async (_input, init) => { + attempt++; + keys.push((init?.headers as Headers).get('idempotency-key') ?? ''); + if (attempt < 2) { + return new Response('upstream error', { status: 503 }); + } + return jsonResponse({ id: 'cand_1', object: 'candidate', email: 'a@b.com', assessment_id: 'asm_1', status: 'invited', invitation_url: 'https://x', invited_at: 't', created_at: 't', updated_at: 't' }, 201); + }, + }); + + await client.candidates.create( + { email: 'a@b.com', assessmentId: 'asm_1' }, + { idempotencyKey: 'deterministic-key-xyz' }, + ); + + expect(keys).toHaveLength(2); + expect(keys[0]).toBe('deterministic-key-xyz'); + expect(keys[1]).toBe('deterministic-key-xyz'); + }); + + it('generates a different Idempotency-Key for each independent call (not shared across calls)', async () => { + const keysPerCall: string[] = []; + + const client = new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 0, + fetch: async (_input, init) => { + keysPerCall.push((init?.headers as Headers).get('idempotency-key') ?? ''); + return jsonResponse({ id: 'cand_1', object: 'candidate', email: 'a@b.com', assessment_id: 'asm_1', status: 'invited', invitation_url: 'https://x', invited_at: 't', created_at: 't', updated_at: 't' }, 201); + }, + }); + + await client.candidates.create({ email: 'a@b.com', assessmentId: 'asm_1' }); + await client.candidates.create({ email: 'b@b.com', assessmentId: 'asm_1' }); + + expect(keysPerCall).toHaveLength(2); + expect(keysPerCall[0]).not.toBe(keysPerCall[1]); + }); +}); + +// --------------------------------------------------------------------------- +// Retry-After header parsing (unit level via backoffDelay) +// --------------------------------------------------------------------------- + +describe('Retry-After header parsing', () => { + it('numeric Retry-After: small / realistic values returned as-is', () => { + // Cap is now 300_000ms (5min) by default, configurable via maxRetryAfterMs. + expect(backoffDelay(0, 5)).toBe(5_000); + expect(backoffDelay(2, 1)).toBe(1_000); + expect(backoffDelay(0, 30)).toBe(30_000); + expect(backoffDelay(0, 60)).toBe(60_000); // realistic rate-limit window + expect(backoffDelay(0, 300)).toBe(300_000); // cap value honored exactly + }); + + it('numeric Retry-After: value over cap falls back to exponential backoff (not silently capped)', () => { + // Per the webhook+retry hardening: hostile/over-cap values are rejected, + // not truncated. Caller falls through to exponential backoff. + const delay = backoffDelay(0, 9_999_999); + expect(delay).toBeLessThanOrEqual(500); // initial exponential band + expect(delay).toBeGreaterThan(0); + }); + + it('HTTP-date Retry-After: parseInt returns NaN — falls back to exponential', () => { + // This documents what request.ts does: parseInt('Wed, 21 Oct...', 10) → NaN + // Number.isFinite(NaN) → false, so retryAfterSeconds is passed as undefined. + const raw = 'Wed, 21 Oct 2025 07:28:00 GMT'; + const parsed = parseInt(raw, 10); + expect(Number.isFinite(parsed)).toBe(false); + + // backoffDelay with undefined falls back to exponential. + const delay = backoffDelay(0, undefined); + expect(delay).toBeGreaterThan(0); + expect(delay).toBeLessThanOrEqual(500); // initial exponential band (INITIAL_MS * 1) + }); + + it('negative Retry-After: backoffDelay ignores it and uses exponential', () => { + // parseInt('-30', 10) === -30; Number.isFinite(-30) === true, BUT + // backoffDelay checks `retryAfterSeconds > 0` before using it. + const delay = backoffDelay(0, -30); + expect(delay).toBeGreaterThan(0); + expect(delay).toBeLessThanOrEqual(500); + }); + + it('NaN Retry-After: backoffDelay falls back to exponential', () => { + const delay = backoffDelay(0, NaN); + expect(delay).toBeGreaterThan(0); + expect(delay).toBeLessThanOrEqual(500); + }); + + it('missing Retry-After (undefined): backoffDelay returns exponential delay', () => { + const delay = backoffDelay(0); + expect(delay).toBeGreaterThan(0); + expect(delay).toBeLessThanOrEqual(500); + }); + + it('integration: request.ts passes numeric Retry-After seconds to backoffDelay correctly', async () => { + /** + * We cannot easily assert the exact sleep duration without mocking timers, + * so we instead assert that after a 429 with Retry-After: 1, the SDK + * succeeds on the second attempt and exactly 2 fetch calls were made. + * The sleep delay is already covered at the unit level above. + */ + let attempt = 0; + const client = new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 1, + fetch: async () => { + attempt++; + if (attempt === 1) { + return new Response('rate limited', { + status: 429, + headers: { 'retry-after': '1' }, // 1 second — test suite can handle this + }); + } + return jsonResponse({ object: 'list', data: [], has_more: false, next_cursor: null }); + }, + }); + + const page = await client.assessments.list(); + expect(page.data).toEqual([]); + expect(attempt).toBe(2); + }, 10_000); + + it('integration: HTTP-date Retry-After falls back to exponential (does not crash)', async () => { + let attempt = 0; + const client = new Langos({ + apiKey: 'sk_test', + baseUrl: BASE, + telemetry: false, + maxRetries: 1, + fetch: async () => { + attempt++; + if (attempt === 1) { + return new Response('rate limited', { + status: 429, + headers: { 'retry-after': 'Wed, 21 Oct 2025 07:28:00 GMT' }, + }); + } + return jsonResponse({ object: 'list', data: [], has_more: false, next_cursor: null }); + }, + }); + + // Should succeed on second attempt without throwing. + const page = await client.assessments.list(); + expect(page.data).toEqual([]); + expect(attempt).toBe(2); + }, 10_000); +}); diff --git a/test/unit/retry.test.ts b/test/unit/retry.test.ts index 1c746b9..719142d 100644 --- a/test/unit/retry.test.ts +++ b/test/unit/retry.test.ts @@ -38,9 +38,49 @@ describe('backoffDelay', () => { expect(backoffDelay(0, 5)).toBe(5_000); expect(backoffDelay(2, 10)).toBe(10_000); }); - it('caps retry-after at 32_000ms', () => { - expect(backoffDelay(0, 1_000)).toBeLessThanOrEqual(32_000); + + it('honors realistic rate-limit Retry-After windows (60s, 300s)', () => { + // The previous 32s cap silently truncated server values 60s and 300s, + // defeating the header. New default cap is 5 minutes. + expect(backoffDelay(0, 60)).toBe(60_000); + expect(backoffDelay(0, 300)).toBe(300_000); + }); + + it('falls back to backoff when retry-after exceeds the cap (default 5min)', () => { + // 99_999_999s is ~3 years. Honoring this would block the request + // forever; fall back to backoff so the partner's caller fast-fails. + const d = backoffDelay(0, 99_999_999); + expect(d).toBeLessThanOrEqual(500); // attempt 0: backoff bounded by INITIAL_MS + }); + + it('rejects negative retry-after as nonsensical (falls back to backoff)', () => { + const d = backoffDelay(0, -10); + // Falls through to randomized exponential backoff; bounded by INITIAL_MS. + expect(d).toBeGreaterThan(0); + expect(d).toBeLessThanOrEqual(500); + }); + + it('rejects NaN retry-after (falls back to backoff)', () => { + const d = backoffDelay(0, NaN); + expect(d).toBeGreaterThan(0); + expect(d).toBeLessThanOrEqual(500); + }); + + it('uses backoff when retry-after is omitted', () => { + const d = backoffDelay(0, undefined); + expect(d).toBeGreaterThan(0); + expect(d).toBeLessThanOrEqual(500); }); + + it('honors a configurable per-client maxRetryAfterMs', () => { + // Caller wants a tighter ceiling — 30s. Server requests 60s. Should + // fall through to backoff rather than honoring the over-cap value. + const d = backoffDelay(0, 60, 30_000); + expect(d).toBeLessThanOrEqual(500); // backoff, not 60_000 + // Within-cap value should still be honored verbatim. + expect(backoffDelay(0, 20, 30_000)).toBe(20_000); + }); + it('grows exponentially without retry-after', () => { const a = backoffDelay(0); const b = backoffDelay(2); diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index 97897d6..314f6dc 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -6,6 +6,7 @@ import { candidateFromWire, candidateCreateToWire, sessionFromWire, + webhookEndpointFromWire, } from '../../src/core/transform.js'; /** @@ -116,6 +117,32 @@ describe('transform: challenge', () => { }); }); +describe('transform: assessment forward-compat', () => { + it('coerces missing challenge_count to 0', () => { + const a = assessmentFromWire({ + id: 'asm_2', + name: 'No counter', + created_at: '2026-05-03T00:00:00Z', + updated_at: '2026-05-03T00:00:00Z', + }); + expect(a.challengeCount).toBe(0); + expect(a.description).toBeNull(); + }); +}); + +describe('transform: challenge forward-compat', () => { + it('falls back to "draft" for an unknown status (deploy-skew safe)', () => { + const c = challengeFromWire({ + id: 'ch_3', + title: 'Future challenge', + language: 'rust', + status: 'in_review_by_AI', + created_at: '2026-05-03T00:00:00Z', + }); + expect(c.status).toBe('draft'); + }); +}); + describe('transform: candidate', () => { it('preserves metadata blob untouched', () => { const meta = { greenhouse_app_id: 'abc', tags: ['urgent', 'follow-up'] }; @@ -164,6 +191,58 @@ describe('transform: candidate', () => { expect(w.external_id).toBeNull(); expect(w.metadata).toBeNull(); }); + + it('coerces missing optional fields to null', () => { + const c = candidateFromWire({ + id: 'cand_2', + email: 'b@b.com', + assessment_id: 'asm_1', + status: 'invited', + created_at: '2026-05-03T00:00:00Z', + updated_at: '2026-05-03T00:00:00Z', + }); + expect(c.name).toBeNull(); + expect(c.externalId).toBeNull(); + expect(c.invitationUrl).toBeNull(); + expect(c.score).toBeNull(); + expect(c.metadata).toBeNull(); + expect(c.latestSessionId).toBeNull(); + }); + + it('forward-compat: unknown status falls back to "error" without throwing', () => { + expect(() => + candidateFromWire({ + id: 'cand_3', + email: 'c@b.com', + assessment_id: 'asm_1', + status: 'rehydrating', + created_at: '2026-05-03T00:00:00Z', + updated_at: '2026-05-03T00:00:00Z', + }), + ).not.toThrow(); + const c = candidateFromWire({ + id: 'cand_3', + email: 'c@b.com', + assessment_id: 'asm_1', + status: 'rehydrating', + created_at: '2026-05-03T00:00:00Z', + updated_at: '2026-05-03T00:00:00Z', + }); + expect(c.status).toBe('error'); + }); + + it('coerces numeric-string score to number (Postgres NUMERIC drivers)', () => { + const c = candidateFromWire({ + id: 'cand_4', + email: 'd@b.com', + assessment_id: 'asm_1', + status: 'completed', + score: '92.5', + created_at: '2026-05-03T00:00:00Z', + updated_at: '2026-05-03T00:00:00Z', + }); + expect(c.score).toBe(92.5); + }); }); describe('transform: account', () => { @@ -236,6 +315,56 @@ describe('transform: account', () => { const a = accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, plan_caps: null }); expect(a.planCaps).toBeNull(); }); + + describe('billingCycle (narrowed enum)', () => { + it('preserves the canonical "monthly" / "yearly" values', () => { + expect( + accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, billing_cycle: 'monthly' }).billingCycle, + ).toBe('monthly'); + expect( + accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, billing_cycle: 'yearly' }).billingCycle, + ).toBe('yearly'); + }); + + it('returns null when the server omits or nulls the field (free tier / no Stripe sub)', () => { + expect( + accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, billing_cycle: null }).billingCycle, + ).toBeNull(); + const { billing_cycle: _omitted, ...withoutCycle } = ACCOUNT_WIRE_FIXTURE; + expect(accountFromWire(withoutCycle).billingCycle).toBeNull(); + }); + + it('forward-compat: unknown future cycle (e.g. "quarterly") falls back to null without throwing', () => { + expect(() => + accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, billing_cycle: 'quarterly' }), + ).not.toThrow(); + expect( + accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, billing_cycle: 'quarterly' }).billingCycle, + ).toBeNull(); + }); + }); + + describe('status (narrowed enum)', () => { + it.each(['trialing', 'active', 'past_due', 'canceled', 'pending'] as const)( + 'preserves the canonical %s value', + raw => { + expect(accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, status: raw }).status).toBe(raw); + }, + ); + + it('forward-compat: unknown status falls back to "active" rather than throwing', () => { + expect(() => + accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, status: 'cosmic_ray_state' }), + ).not.toThrow(); + expect( + accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, status: 'cosmic_ray_state' }).status, + ).toBe('active'); + }); + + it('null status falls back to "active"', () => { + expect(accountFromWire({ ...ACCOUNT_WIRE_FIXTURE, status: null }).status).toBe('active'); + }); + }); }); describe('transform: session', () => { @@ -256,6 +385,50 @@ describe('transform: session', () => { expect(s.passed).toBeNull(); }); + it('forward-compat: unknown status falls back to "pending" without throwing', () => { + expect(() => + sessionFromWire({ + id: 'sess_2', + candidate_id: 'cand_1', + assessment_id: 'asm_1', + status: 'mirror_universe', + created_at: '2026-05-03T00:00:00Z', + updated_at: '2026-05-03T00:00:00Z', + }), + ).not.toThrow(); + const s = sessionFromWire({ + id: 'sess_2', + candidate_id: 'cand_1', + assessment_id: 'asm_1', + status: 'mirror_universe', + created_at: '2026-05-03T00:00:00Z', + updated_at: '2026-05-03T00:00:00Z', + }); + expect(s.status).toBe('pending'); + }); + + it('insights with all known fields narrow to number | null (no extras leak)', () => { + const s = sessionFromWire({ + id: 'sess_3', + candidate_id: 'cand_1', + assessment_id: 'asm_1', + status: 'completed', + insights: { + ai_usage_percent: 42, + test_pass_rate: 0.8, + code_quality: { lint_warnings: 3 }, + }, + created_at: '2026-05-03T00:00:00Z', + updated_at: '2026-05-03T00:00:00Z', + }); + expect(s.insights).not.toBeNull(); + expect(s.insights?.aiUsagePercent).toBe(42); + expect(s.insights?.testPassRate).toBe(0.8); + expect(s.insights?.codeQuality).toEqual({ lint_warnings: 3 }); + // Index sig dropped — only the three documented keys are present. + expect(Object.keys(s.insights!).sort()).toEqual(['aiUsagePercent', 'codeQuality', 'testPassRate']); + }); + it('maps submission and feedback when present', () => { const s = sessionFromWire({ id: 'sess_1', @@ -288,3 +461,21 @@ describe('transform: session', () => { expect(s.score).toBe(87); }); }); + +describe('transform: webhook endpoint', () => { + it('maps populated wire shape', () => { + const e = webhookEndpointFromWire({ + object: 'webhook_endpoint', + webhook_url: 'https://hooks.example.com/langos', + signing_secret: 'whsec_abc', + }); + expect(e.webhookUrl).toBe('https://hooks.example.com/langos'); + expect(e.signingSecret).toBe('whsec_abc'); + }); + + it('coerces missing fields to null', () => { + const e = webhookEndpointFromWire({}); + expect(e.webhookUrl).toBeNull(); + expect(e.signingSecret).toBeNull(); + }); +}); diff --git a/test/unit/webhooks.test.ts b/test/unit/webhooks.test.ts index 6223030..cbb9ec5 100644 --- a/test/unit/webhooks.test.ts +++ b/test/unit/webhooks.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from 'vitest'; import { createHmac } from 'node:crypto'; import { Webhooks } from '../../src/resources/webhooks.js'; -import { LangosSignatureVerificationError } from '../../src/core/errors.js'; +import { + LangosSignatureVerificationError, + LangosWebhookPayloadError, +} from '../../src/core/errors.js'; // Real signing secrets are at least 32 random bytes server-side. Use a // 16+ char fixture to satisfy the SDK's minimum-length check while staying @@ -74,10 +77,18 @@ describe('Webhooks.constructEvent', () => { ); }); - it('rejects non-JSON payload after sig passes', () => { + it('rejects non-JSON payload after sig passes with LangosWebhookPayloadError (NOT signature error)', () => { const garbage = 'not-json'; const sig = `t=${now},v1=${sign(now, garbage)}`; + // Signature verifies, but body is junk — this is a producer/proxy bug, + // not forgery. Distinct error class so partner code can branch on the + // remediation (file upstream ticket vs rotate secret). expect(() => Webhooks.constructEvent(garbage, sig, SECRET)).toThrow( + LangosWebhookPayloadError, + ); + // Defensively: must NOT be the signature error (subclasses both extend + // LangosError, and we explicitly want them to be distinguishable). + expect(() => Webhooks.constructEvent(garbage, sig, SECRET)).not.toThrow( LangosSignatureVerificationError, ); }); @@ -145,4 +156,42 @@ describe('Webhooks.constructEvent', () => { ); }); }); + + // Some proxies / Node frameworks expose duplicate `Langos-Signature` + // headers as a string[]. The previous implementation took only [0] and + // silently dropped signatures from rotation copies. We now join with `,` + // so every v1= entry from every copy gets considered. + describe('multi-value (string[]) signature header', () => { + it('joins multi-value array so a signature in a later entry still matches', () => { + // First entry has a bogus signature; the second has the real one. + // Pre-fix this would have thrown "no signatures matched" because + // [0] was selected and [1] was discarded. + const sigs = [`t=${now},v1=${'aa'.repeat(32)}`, `t=${now},v1=${sign(now, body)}`]; + const evt = Webhooks.constructEvent(body, sigs, SECRET); + expect(evt.id).toBe('evt_1'); + }); + + it('rejects empty array as missing header (does not crash on [0]!)', () => { + // Pre-fix: `signatureHeader[0]!` on `[]` crashes with TS non-null + // assertion runtime undefined; post-fix: throws a clear error. + expect(() => Webhooks.constructEvent(body, [], SECRET)).toThrow( + LangosSignatureVerificationError, + ); + expect(() => Webhooks.constructEvent(body, [], SECRET)).toThrow( + /Missing Langos-Signature header/, + ); + }); + }); + + it('rejects signature header longer than 4096 chars (CPU-DoS guard)', () => { + // Real Langos-Signature is ~80 chars. 5 KiB of garbage is unambiguously + // attacker-controlled — refuse to even tokenize. + const huge = 'v1=' + 'a'.repeat(5_000); + expect(() => Webhooks.constructEvent(body, huge, SECRET)).toThrow( + LangosSignatureVerificationError, + ); + expect(() => Webhooks.constructEvent(body, huge, SECRET)).toThrow( + /exceeds 4096 chars/, + ); + }); });