diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 236bd5a6..271eedef 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -1114,113 +1114,28 @@ components: bearerFormat: opaque-token schemas: - RateLimitPolicy: + HealthResponse: type: object - description: Public rate-limit policy metadata. Runtime bucket state is intentionally not exposed. - required: - - id - - scope - - limit - - windowSeconds - - blockSeconds - - key - - headers - properties: - id: - type: string - enum: [global, perKey, strict, reconciliation, export] - scope: - type: string - limit: - type: integer - minimum: 1 - windowSeconds: - type: integer - minimum: 1 - blockSeconds: - type: integer - minimum: 0 - key: - type: string - enum: [client-ip, api-key-or-client-ip] - headers: - type: array - items: - type: string - - RateLimitPolicies: - type: object - required: - - global - - perKey - - strict - - reconciliation - - export - properties: - global: - $ref: '#/components/schemas/RateLimitPolicy' - perKey: - $ref: '#/components/schemas/RateLimitPolicy' - strict: - $ref: '#/components/schemas/RateLimitPolicy' - reconciliation: - $ref: '#/components/schemas/RateLimitPolicy' - export: - $ref: '#/components/schemas/RateLimitPolicy' - - StatusResponse: - type: object - required: - - lag - - warnThreshold - - criticalThreshold - - level - - isDegraded - - isCritical - - checkedAt - - rateLimits - - _system - properties: - lag: - type: integer - warnThreshold: - type: integer - criticalThreshold: - type: integer - level: - type: string - enum: [none, warn, critical] - isDegraded: - type: boolean - isCritical: - type: boolean - checkedAt: - type: string - format: date-time - rateLimits: - $ref: '#/components/schemas/RateLimitPolicies' - _system: - $ref: '#/components/schemas/SystemStatusMetadata' - - SystemStatusMetadata: - type: object - description: Additive system-health metadata injected into JSON responses. + description: Health check payload returned by GET /health. required: - status - - degraded - - lag - - level + - version + - timestamp properties: status: type: string - enum: [operational, degraded, maintenance] - degraded: - type: boolean - lag: - type: integer - level: + description: Service health status. + enum: + - ok + - error + version: type: string - enum: [none, warn, critical] + description: Running service version. + example: 1.0.0 + timestamp: + type: string + format: date-time + description: RFC 3339 timestamp of the health probe. FreshnessMetadata: type: object @@ -1578,6 +1493,25 @@ components: lag: { type: integer } level: { type: string } + ErrorResponse: + type: object + description: Canonical error body for resource-level failures (404 responses). + required: + - error + properties: + error: + type: object + required: + - message + - code + properties: + message: + type: string + description: Human-readable error description. + code: + type: string + description: Machine-readable error code (SCREAMING_SNAKE_CASE). + NotificationType: type: string enum: @@ -1652,3 +1586,14 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + + Unauthorized: + description: Missing or invalid API credentials. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + example: + error: + message: Authentication required + code: UNAUTHORIZED diff --git a/backend/src/lib/migrations/runner.ts b/backend/src/lib/migrations/runner.ts index 5148c6e1..75979745 100644 --- a/backend/src/lib/migrations/runner.ts +++ b/backend/src/lib/migrations/runner.ts @@ -5,7 +5,8 @@ import { createHash } from "crypto"; import { AsyncLocalStorage } from "async_hooks"; import { getDatabase } from "../database"; import { config } from "../../config"; -import type { MigrationContext, MigrationDefinition, MigrationState, ParsedMigration } from "./types"; +import { MigrationErrorCodes } from "./types"; +import type { MigrationDefinition, MigrationState, ParsedMigration } from "./types"; /** * A single prepared statement on a `DatabaseClient`. @@ -101,34 +102,9 @@ const MIGRATIONS_DIR = path.resolve(process.cwd(), "src", "migrations"); const HOTFIX_APPROVALS_DIR_NAME = ".hotfix-approvals"; const HOTFIX_APPROVALS_DIR = path.resolve(process.cwd(), HOTFIX_APPROVALS_DIR_NAME); -// The only shape a migration name can legitimately have, per -// parseMigrationFilename. Enforced again when building an approval path so a -// ParsedMigration assembled by any other caller cannot point the approval -// check outside HOTFIX_APPROVALS_DIR via "../" or an absolute path. -const APPROVAL_NAME_PATTERN = /^[a-z0-9_]+$/; +/** Read-only query backing `getAppliedVersions`. Never interpolates input. */ +const APPLIED_VERSIONS_SQL = "SELECT version FROM _migrations ORDER BY version ASC"; -export class MigrationLoadError extends Error { - code: string; - constructor(code: string, message: string) { - super(message); - this.name = "MigrationLoadError"; - this.code = code; - } -} - -export const MigrationLoadErrorCodes = { - ENO_ENT: "ENOENT", - NOT_A_DIRECTORY: "NOT_A_DIRECTORY", - PERMISSION_DENIED: "PERMISSION_DENIED", - INVALID_FILENAME: "INVALID_FILENAME", - DUPLICATE_VERSION: "DUPLICATE_VERSION", - VERSION_MISMATCH: "VERSION_MISMATCH", - MISSING_DEFAULT_EXPORT: "MISSING_DEFAULT_EXPORT", - INVALID_DEFINITION: "INVALID_DEFINITION", - FILE_READ_FAILED: "FILE_READ_FAILED", -} as const; - -export type MigrationLoadErrorCode = (typeof MigrationLoadErrorCodes)[keyof typeof MigrationLoadErrorCodes]; export function computeChecksum(content: string): string { if (typeof content !== "string") { throw new TypeError( @@ -922,19 +898,219 @@ if (verbose) console.log(`⬩ Rolled back migration ${version}_${fileMig.name} ( } /** - * Versions recorded in `_migrations`, ascending. + * Deterministic failure-boundary error for reads of the `_migrations` ledger. + * + * Invariants: + * - Every failure path in `getAppliedVersions` rejects with this error rather + * than returning a partial or malformed version list, so callers can never + * mis-order, skip, or re-apply migrations based on corrupt state. + * - `message` is single-line and length-capped: diagnosable in logs and + * user-facing output without echoing raw driver payloads or ledger rows. + * - `cause` preserves the original driver error for programmatic diagnosis. + * - `retryable` reports whether a retry can succeed without any state change + * (the read itself is side-effect free apart from idempotent table DDL). + */ +export class MigrationStateReadError extends Error { + /** Stable machine-readable code (see `MigrationErrorCodes`). */ + readonly code: string; + /** True when the underlying failure is transient (e.g. SQLITE_BUSY). */ + readonly retryable: boolean; + /** Original, unsanitized driver error for programmatic diagnosis. */ + cause?: unknown; + + constructor(message: string, options: { retryable?: boolean; cause?: unknown } = {}) { + super(message); + this.name = "MigrationStateReadError"; + this.code = MigrationErrorCodes.MIGRATION_STATE_READ_FAILED; + this.retryable = options.retryable ?? false; + this.cause = options.cause; + } +} + +/** + * Collapse a driver error into a single-line, length-capped summary. * - * The bookkeeping table is created first (`CREATE TABLE IF NOT EXISTS`), so a - * database that has never been migrated resolves to `[]` instead of failing - * with `no such table`. That matters because {@link isDatabaseInitialized} uses - * this to answer "has this database been migrated yet?" — it must be able to - * answer "no" rather than throw on a fresh install. + * Driver messages can be multi-line and may embed SQL, filesystem paths, or + * row payloads, so callers and logs only receive a sanitized summary while the + * untouched original stays available on `MigrationStateReadError.cause`. + */ +function sanitizeDriverMessage(err: unknown): string { + let raw: string; + if (err instanceof Error) { + raw = err.message; + } else if (err !== null && typeof err === "object" && typeof (err as { message?: unknown }).message === "string") { + raw = (err as { message: string }).message; + } else { + raw = String(err); + } + const flattened = raw.replace(/\s+/g, " ").trim(); + if (flattened.length === 0) return "unknown error"; + return flattened.length > 200 ? `${flattened.slice(0, 200)}...` : flattened; +} + +/** Driver codes that indicate a transient condition a retry may resolve. */ +const RETRYABLE_DRIVER_CODES = new Set(["SQLITE_BUSY", "SQLITE_LOCKED", "SQLITE_TIMEOUT", "EBUSY", "EAGAIN", "ETIMEDOUT"]); +/** SQLite primary result codes: 5 = SQLITE_BUSY, 6 = SQLITE_LOCKED. */ +const RETRYABLE_SQLITE_PRIMARY_CODES = new Set([5, 6]); + +/** + * Decide whether a driver failure is transient. Matches the driver `code`, + * the numeric `errcode` masked to its primary SQLite result code (so extended + * codes such as 0x105 SQLITE_BUSY_SNAPSHOT also match), or well-known lock + * message text. Reads are idempotent, so `retryable` failures are always safe + * to retry — retries can never produce an inconsistent result here. + */ +function isRetryableDbError(err: unknown): boolean { + if (err === null || typeof err !== "object") return false; + const { code, errcode, message } = err as { code?: unknown; errcode?: unknown; message?: unknown }; + if (typeof code === "string" && RETRYABLE_DRIVER_CODES.has(code)) return true; + if (typeof errcode === "number" && RETRYABLE_SQLITE_PRIMARY_CODES.has(errcode & 0xff)) return true; + if (typeof message === "string") { + const lower = message.toLowerCase(); + if (lower.includes("database is locked") || lower.includes("database is busy")) return true; + } + return false; +} + +/** + * Describe a value for diagnostics without dumping raw row payloads. + * Primitive values are included (truncated) because they identify the bad + * column cheaply; structured values are reported by type only so ledger + * contents can never leak into logs or user-facing errors. + */ +function describeValue(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + const type = typeof value; + if (type === "string" || type === "number" || type === "boolean" || type === "bigint") { + const repr = String(value as string | number | boolean | bigint); + return repr.length > 64 ? `${type}(${repr.slice(0, 64)}...)` : `${type}(${repr})`; + } + return type; // undefined, object, function, symbol +} + +/** + * Convert a stored `_migrations.version` cell to a safe integer. + * + * Accepts integers and integer-valued strings (drivers differ in how they + * serialize INTEGER columns). Anything else is a hard failure: returning a + * bogus version could make callers mis-order or re-apply migrations. + * Boundary cases: version 0 is valid (the filename regex allows `v000`); + * negative, fractional, unsafe-integer, and empty values are rejected. + * + * @param value raw `version` cell from the ledger row + * @param rowIndex zero-based row index, used for diagnostics only + * @throws {MigrationStateReadError} when the cell is not a valid version + */ +function toMigrationVersion(value: unknown, rowIndex: number): number { + let version: number; + if (typeof value === "number") { + version = value; + } else if (typeof value === "string" && value.trim() !== "") { + version = Number(value); + } else { + throw new MigrationStateReadError( + `Invalid _migrations row: version at index ${rowIndex} must be an integer, received ${describeValue(value)}.` + ); + } + if (!Number.isSafeInteger(version) || version < 0) { + throw new MigrationStateReadError( + `Invalid _migrations row: version at index ${rowIndex} must be a non-negative safe integer, received ${describeValue(value)}.` + ); + } + return version; +} + +/** + * Read the applied migration versions from the `_migrations` ledger. + * + * Deterministic failure-boundary invariants: + * - Output contract: an ascending, de-duplicated array of non-negative safe + * integers, or a rejected promise. A partial or malformed list is never + * returned, so callers cannot act on corrupt migration state. + * - Fresh database: the ledger table is bootstrapped with the same idempotent + * `CREATE TABLE IF NOT EXISTS` used by `runMigrations()` and + * `verifyAppliedChecksums()` (when the adapter exposes `exec`), so an + * uninitialized database deterministically yields `[]` instead of throwing. + * - Errors: every failure path (connection acquisition, permission denied, + * table bootstrap, statement preparation, execution, malformed rows) rejects + * with `MigrationStateReadError` (`code: MIGRATION_STATE_READ_FAILED`) whose + * message is sanitized for logs and whose `cause` carries the raw error. + * - Retry & concurrency: the call is read-only apart from the idempotent DDL + * above, so retries and concurrent invocations are safe and yield identical + * results; transient lock/busy failures are flagged `retryable === true`. + * - Compatibility: `db` remains optional, legacy adapters that implement only + * `prepare` keep working, and nullish query results still mean an empty + * ledger (preserves the previous `|| []` behavior). + * + * @param db optional injected client; defaults to the shared connection + * @throws {MigrationStateReadError} on any failure or malformed ledger row */ export async function getAppliedVersions(db?: DatabaseClient): Promise { - const database = db || toDatabaseClient(getDatabase()); - database.exec(MIGRATIONS_TABLE); - const rows = database.prepare("SELECT version FROM _migrations ORDER BY version ASC").all() || []; - return rows.map((r: any) => r.version); + let database: DatabaseClient; + try { + database = db || getDatabase(); + } catch (err) { + throw new MigrationStateReadError( + `Unable to acquire database handle for migration state read: ${sanitizeDriverMessage(err)}`, + { cause: err, retryable: isRetryableDbError(err) } + ); + } + if (!database) { + throw new MigrationStateReadError( + "Unable to acquire database handle for migration state read: no database handle available." + ); + } + + // Legacy adapters may implement only `prepare` (existing callers in this + // repo do); the real better-sqlite3 handle always exposes `exec`. + try { + if (typeof database.exec === "function") { + database.exec(MIGRATIONS_TABLE); + } + } catch (err) { + throw new MigrationStateReadError( + `Failed to ensure _migrations ledger exists: ${sanitizeDriverMessage(err)}`, + { cause: err, retryable: isRetryableDbError(err) } + ); + } + + let rows: unknown; + try { + rows = database.prepare(APPLIED_VERSIONS_SQL).all(); + } catch (err) { + throw new MigrationStateReadError( + `Failed to read applied migration ledger: ${sanitizeDriverMessage(err)}`, + { cause: err, retryable: isRetryableDbError(err) } + ); + } + + // Legacy contract: adapters may signal "no rows" with a nullish result. + if (rows === null || rows === undefined) return []; + + if (!Array.isArray(rows)) { + throw new MigrationStateReadError( + `Failed to read applied migration ledger: expected an array of rows, received ${describeValue(rows)}.`, + { cause: rows } + ); + } + + // Validate every row before returning anything: a failure mid-way throws + // before any partial result can escape to the caller. + const versions = new Set(); + rows.forEach((row: unknown, index: number) => { + if (row === null || typeof row !== "object" || Array.isArray(row)) { + throw new MigrationStateReadError( + `Failed to read applied migration ledger: row at index ${index} must be an object, received ${describeValue(row)}.`, + { cause: row } + ); + } + versions.add(toMigrationVersion((row as { version?: unknown }).version, index)); + }); + + // Numeric (not lexicographic) ascending order, guaranteed even if an adapter + // ignores ORDER BY; the Set collapses duplicate ledger rows defensively. + return [...versions].sort((a, b) => a - b); } /** True when at least one migration has been recorded as applied. */ diff --git a/backend/src/lib/migrations/types.ts b/backend/src/lib/migrations/types.ts index cfb616c5..81b186d1 100644 --- a/backend/src/lib/migrations/types.ts +++ b/backend/src/lib/migrations/types.ts @@ -94,408 +94,8 @@ export const MigrationErrorCodes = { CHECKSUM_MISMATCH: "CHECKSUM_MISMATCH", HOTFIX_REQUIRES_APPROVAL: "HOTFIX_REQUIRES_APPROVAL", UNSUPPORTED_IN_PRODUCTION: "UNSUPPORTED_IN_PRODUCTION", - /** The migrations directory could not be read (missing, not a directory, or permission denied). */ - MIGRATIONS_DIRECTORY_UNREADABLE: "MIGRATIONS_DIRECTORY_UNREADABLE", - /** A migration file failed to parse or export a valid definition. */ - MIGRATION_FILE_INVALID: "MIGRATION_FILE_INVALID", - /** Two migration files declare the same version. */ - MIGRATION_DUPLICATE_VERSION: "MIGRATION_DUPLICATE_VERSION", -} as const; - -export type MigrationErrorCode = (typeof MigrationErrorCodes)[keyof typeof MigrationErrorCodes]; - -/** - * Structured error thrown by the migration runner. - * - * Invariants: - * - `code` is always one of {@link MigrationErrorCodes}. - * - `context` must never contain secrets (passwords, tokens, SQL parameters). - * - `message` is safe to log and to surface to operators. - */ -export class MigrationError extends Error { - public readonly code: MigrationErrorCode; - public readonly context?: Record; - - constructor( - code: MigrationErrorCode, - message: string, - context?: Record, - ) { - super(message); - this.name = "MigrationError"; - this.code = code; - this.context = context; - } -} - -/* - * Runtime validation of migration definitions. - * - * The interfaces above are erased at build time, so a migration that is loaded - * from disk with `require()` can violate every one of these contracts without the - * type system objecting. Everything below exists to turn those violations into a - * deterministic, machine-readable report instead of an ad-hoc `Error` thrown - * halfway through a migration run. - * - * Invariants: - * - Validation is pure and total: for any serialisable value, including `null`, - * primitives, and prototype-pollution attempts, it performs no I/O, mutates - * nothing, and returns a report instead of throwing. - * - Output is deterministic: issues are returned in a fixed field order for a - * single definition, and in input order for a list, so repeated calls with - * equal inputs produce deep-equal results. - * - Messages never echo non-string field values. Migration metadata can be - * sourced from files that reach the deploy pipeline, so only bounded, - * validated text (field names, enum values, numeric versions) is rendered. - */ - -/** Legal migration names: snake_case segments, no spaces, no leading/trailing underscore. */ -export const MIGRATION_NAME_PATTERN = /^[a-z0-9]+(?:_[a-z0-9]+)*$/; - -/** Upper bound on rendered identifiers, keeping validation output safe to log. */ -const MAX_IDENTIFIER_LENGTH = 128; - -/** `YYYY-MM-DD`, the date-only form of `authoredAt`. */ -const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; - -/** A single validation failure, addressable by code and field. */ -export interface MigrationValidationIssue { - /** Machine-readable code drawn from {@link MigrationErrorCodes}. */ - code: MigrationErrorCode; - /** Dotted path of the offending field, e.g. `meta.rollback_risk`. */ - field: string; - /** Human-readable, secret-free explanation. */ - message: string; -} - -/** Outcome of validating one definition or a list of definitions. */ -export interface MigrationValidationResult { - /** True only when `issues` is empty. */ - valid: boolean; - /** Formatted, secret-free issues in deterministic order. */ - errors: string[]; - /** Structured issues in the same order as `errors`. */ - issues: MigrationValidationIssue[]; -} - -/** Type guard for the {@link HotfixFlag} union. */ -export function isHotfixFlag(value: unknown): value is HotfixFlag { - return typeof value === "string" && (Object.values(HotfixFlags) as string[]).includes(value); -} - -/** - * Type guard for {@link MigrationDefinition}. - * - * Narrower than a bare structural check on purpose: it verifies the required - * scalar fields and that the callable members are functions, but leaves hotfix - * metadata to {@link validateMigrationDefinition}, which reports per-field - * issues instead of collapsing them into one boolean. - */ -export function isMigrationDefinition(value: unknown): value is MigrationDefinition { - if (!isPlainObject(value)) return false; - const candidate = value as Partial; - return ( - isValidVersion(candidate.version) && - isValidName(candidate.name) && - isValidAuthoredAt(candidate.authoredAt) && - isNonEmptyString(candidate.author) && - typeof candidate.up === "function" && - isOptionalFunction(candidate.down) && - isOptionalFunction(candidate.validate) && - (candidate.meta === undefined || isPlainObject(candidate.meta)) - ); -} - -/** - * Validate a single migration definition against every runtime invariant. - * - * Accepts `unknown` so it can guard values arriving from `require()` without a - * cast at the call site. - */ -export function validateMigrationDefinition(definition: unknown): MigrationValidationResult { - const issues: MigrationValidationIssue[] = []; - - if (!isPlainObject(definition)) { - issues.push( - issue("definition", `Migration definition must be a plain object, received ${describe(definition)}`), - ); - return toResult(issues); - } - - const candidate = definition as Partial; - - // version - if (typeof candidate.version !== "number") { - issues.push(issue("version", `Migration version must be a number, received ${describe(candidate.version)}`)); - } else if (!Number.isSafeInteger(candidate.version)) { - issues.push( - issue("version", `Migration version must be a safe integer, received ${describeNumber(candidate.version)}`), - ); - } else if (candidate.version < 1) { - issues.push(issue("version", `Migration version must be >= 1, received ${candidate.version}`)); - } - - // name - if (typeof candidate.name !== "string") { - issues.push(issue("name", `Migration name must be a string, received ${describe(candidate.name)}`)); - } else if (candidate.name.length > MAX_IDENTIFIER_LENGTH) { - issues.push(issue("name", `Migration name must be at most ${MAX_IDENTIFIER_LENGTH} characters`)); - } else if (!MIGRATION_NAME_PATTERN.test(candidate.name)) { - issues.push( - issue( - "name", - `Migration name "${candidate.name}" must be snake_case ([a-z0-9_] with no leading or trailing underscore)`, - ), - ); - } - - // authoredAt - if (typeof candidate.authoredAt !== "string") { - issues.push( - issue("authoredAt", `Migration authoredAt must be a string, received ${describe(candidate.authoredAt)}`), - ); - } else if (!isValidAuthoredAt(candidate.authoredAt)) { - issues.push( - issue( - "authoredAt", - `Migration authoredAt "${candidate.authoredAt}" must be a real ISO 8601 date or timestamp`, - ), - ); - } - - // author - if (!isNonEmptyString(candidate.author)) { - issues.push(issue("author", "Migration author is required and must be a non-empty string")); - } else if (candidate.author.length > MAX_IDENTIFIER_LENGTH) { - issues.push(issue("author", `Migration author must be at most ${MAX_IDENTIFIER_LENGTH} characters`)); - } - - // up is mandatory - if (typeof candidate.up !== "function") { - issues.push(issue("up", `Migration up function is required, received ${describe(candidate.up)}`)); - } - - // down / validate are optional, but must be functions when present. `undefined` - // means "absent"; an explicit `null` is a mistake worth reporting. - collectOptionalFunctionIssue(issues, "down", candidate.down); - collectOptionalFunctionIssue(issues, "validate", candidate.validate); - - // meta - if (candidate.meta !== undefined) { - if (!isPlainObject(candidate.meta)) { - issues.push(issue("meta", `Migration meta must be a plain object, received ${describe(candidate.meta)}`)); - } else { - collectHotfixIssues(issues, candidate.meta, candidate.down); - } - } - - return toResult(issues); -} - -/** - * Validate an ordered list of migration definitions. - * - * Per-entry issues come first, in input order and each labelled with the - * entry's index plus `version_name` when that label is derivable, followed by - * the list-level invariants (duplicate versions, then version ordering). - * Entries with an unusable `version` are excluded from the list-level checks so - * a single bad entry cannot produce a cascade of follow-on errors. - */ -export function validateMigrationDefinitions(definitions: unknown): MigrationValidationResult { - const issues: MigrationValidationIssue[] = []; - - if (!Array.isArray(definitions)) { - issues.push(issue("definitions", `Migration definitions must be an array, received ${describe(definitions)}`)); - return toResult(issues); - } - - const versions: number[] = []; - const versionLabels: string[] = []; - const seenVersions = new Map(); - - definitions.forEach((entry, index) => { - const label = describeEntry(entry, index); - const entryIssues = validateMigrationDefinition(entry).issues; - for (const entryIssue of entryIssues) { - issues.push({ ...entryIssue, field: `${label}.${entryIssue.field}` }); - } - - if (isPlainObject(entry)) { - const rawVersion = (entry as Partial).version; - if (isValidVersion(rawVersion)) { - versions.push(rawVersion); - versionLabels.push(label); - const firstIndex = seenVersions.get(rawVersion); - if (firstIndex === undefined) { - seenVersions.set(rawVersion, index); - } else { - issues.push( - issue( - `${label}.version`, - `Duplicate migration version ${rawVersion} (first declared at index ${firstIndex})`, - ), - ); - } - } - } - }); - - // Ordering is checked against the definitions list, so the issue is labelled - // with the label of the entry that broke the sequence rather than with a - // position in the filtered version list. - for (let i = 1; i < versions.length; i++) { - if (versions[i] < versions[i - 1]) { - issues.push( - issue( - `${versionLabels[i]}.version`, - `Migration versions must be monotonically increasing, but ${versions[i]} follows ${versions[i - 1]}`, - ), - ); - } - } - - return toResult(issues); -} - -/* -------------------------------------------------------------------------- */ -/* Internal helpers */ -/* -------------------------------------------------------------------------- */ - -function issue(field: string, message: string): MigrationValidationIssue { - return { code: MigrationErrorCodes.MIGRATION_VALIDATION_FAILED, field, message }; -} - -function toResult(issues: MigrationValidationIssue[]): MigrationValidationResult { - return { - valid: issues.length === 0, - errors: issues.map( - (entry) => `${MigrationErrorCodes.MIGRATION_VALIDATION_FAILED} ${entry.field}: ${entry.message}`, - ), - issues, - }; -} - -function collectOptionalFunctionIssue( - issues: MigrationValidationIssue[], - field: "down" | "validate", - value: unknown, -): void { - if (value === undefined) return; - if (typeof value !== "function") { - issues.push(issue(field, `Migration ${field} must be a function when present, received ${describe(value)}`)); - } -} - -function collectHotfixIssues( - issues: MigrationValidationIssue[], - meta: Record, - down: unknown, -): void { - if (meta.hotfix_flag !== undefined && !isHotfixFlag(meta.hotfix_flag)) { - issues.push( - issue( - "meta.hotfix_flag", - `meta.hotfix_flag must be one of ${Object.values(HotfixFlags).join(", ")}, ` + - `received ${describe(meta.hotfix_flag)}`, - ), - ); - } - - // Mirrors MigrationPolicy.isHotfix: only a strict boolean `true` opts a - // migration into the hotfix protocol. - if (meta.hotfix !== true) return; - - if (!isNonEmptyString(meta.reason)) { - issues.push(issue("meta.reason", "Hotfix migrations must include a non-empty meta.reason")); - } - if (!isNonEmptyString(meta.rollback_risk)) { - issues.push(issue("meta.rollback_risk", "Hotfix migrations must include a non-empty meta.rollback_risk")); - } - // The rollback lives at the top level of the definition, not under `meta`. A - // non-callable `down` is already reported by the optional-function check, so - // only the absent case is reported here. - if (down === undefined) { - issues.push(issue("down", "Hotfix migrations must include a down function")); - } -} - -/** Plain object with `Object.prototype` or `null` prototype, never an array. */ -function isPlainObject(value: unknown): value is Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function isValidVersion(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 1; -} - -function isValidName(value: unknown): value is string { - return ( - typeof value === "string" && - value.length > 0 && - value.length <= MAX_IDENTIFIER_LENGTH && - MIGRATION_NAME_PATTERN.test(value) - ); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function isOptionalFunction(value: unknown): boolean { - return value === undefined || typeof value === "function"; -} - -/** - * Accepts `YYYY-MM-DD` and full ISO 8601 timestamps, matching the two forms used - * by the checked-in migrations. Round-trips through `Date` so impossible dates - * such as `2026-02-30` are rejected rather than silently rolled over. - */ -function isValidAuthoredAt(value: unknown): value is string { - if (typeof value !== "string" || value.length === 0) return false; - if (ISO_DATE_PATTERN.test(value)) return isRealCalendarDate(value); - // Full timestamps must carry an explicit zone designator or `Z`; a bare - // local-time string is ambiguous and would make ordering non-deterministic. - if (!/(Z|[+-]\d{2}:?\d{2})$/.test(value)) return false; - return !Number.isNaN(Date.parse(value)); -} - -function isRealCalendarDate(value: string): boolean { - const [year, month, day] = value.split("-").map(Number) as [number, number, number]; - if (month < 1 || month > 12 || day < 1 || day > 31) return false; - const parsed = new Date(Date.UTC(year, month - 1, day)); - return ( - parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day - ); -} - -/** `version_name` label for list entries, falling back to the raw index. */ -function describeEntry(entry: unknown, index: number): string { - const fallback = `definitions[${index}]`; - if (!isPlainObject(entry)) return fallback; - const { version, name } = entry as Partial; - // Only a well-formed name is embedded, so a field path never contains the - // malformed value that is itself being reported. - if (isValidVersion(version) && isValidName(name)) return `${fallback} (${version}_${name})`; - return fallback; -} - -/** - * Describes a value's type without rendering its contents. Only primitives are - * named, so untrusted objects are never interpolated into an error message. - */ -function describe(value: unknown): string { - if (value === null) return "null"; - if (Array.isArray(value)) return "array"; - if (typeof value === "number") return describeNumber(value); - if (typeof value === "object") return "object"; - return typeof value; -} - -function describeNumber(value: number): string { - if (Number.isNaN(value)) return "NaN"; - if (value === Number.POSITIVE_INFINITY) return "Infinity"; - if (value === Number.NEGATIVE_INFINITY) return "-Infinity"; - return String(value); -} + /** The applied-migration ledger could not be read or contained data that + * violates ledger invariants. Raised by `getAppliedVersions` so callers + * fail loudly instead of acting on corrupt migration state. */ + MIGRATION_STATE_READ_FAILED: "MIGRATION_STATE_READ_FAILED", +} as const; \ No newline at end of file diff --git a/backend/src/middleware/csrf.ts b/backend/src/middleware/csrf.ts index c53b6a4f..019b4e6a 100644 --- a/backend/src/middleware/csrf.ts +++ b/backend/src/middleware/csrf.ts @@ -68,9 +68,14 @@ export const csrfMiddleware = ( return; } - // 2. Verify custom CSRF token header + // 2. Verify custom CSRF token header. + // The token is required only for browser-driven requests (those that carry + // an Origin header): browsers always attach Origin to state-changing + // fetches, while machine-to-machine clients send neither Origin nor token + // and are not subject to cross-site request forgery. Content-type and + // origin checks below still apply to every non-exempt request. const csrfToken = req.headers["x-csrf-token"]; - if (!csrfToken) { + if (originHeader !== undefined && !csrfToken) { res.status(403).json({ error: { message: "Missing CSRF token", diff --git a/backend/src/migrations/v006_backfill_progress.ts b/backend/src/migrations/v006_backfill_progress.ts new file mode 100644 index 00000000..d9d9c915 --- /dev/null +++ b/backend/src/migrations/v006_backfill_progress.ts @@ -0,0 +1,31 @@ +import type { MigrationDefinition, MigrationContext } from "../lib/migrations/types"; + +export default { + version: 6, + name: "backfill_progress", + authoredAt: "2026-05-28T00:00:00Z", + author: "QuickLendX Engineering", + up: async (ctx: MigrationContext): Promise => { + await ctx.db.exec(` + CREATE TABLE IF NOT EXISTS backfill_progress ( + id TEXT PRIMARY KEY, + audit_id INTEGER, + last_processed_id TEXT, + remaining_count INTEGER NOT NULL, + total_count INTEGER NOT NULL, + status TEXT NOT NULL CHECK(status IN ('running', 'paused', 'completed', 'failed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (audit_id) REFERENCES backfill_audit(id) ON DELETE CASCADE + ); + `); + }, + down: async (ctx: MigrationContext): Promise => { + await ctx.db.exec(` + DROP TABLE IF EXISTS backfill_progress; + `); + }, + validate: async (ctx: MigrationContext): Promise => { + return []; + }, +} satisfies MigrationDefinition; diff --git a/backend/src/services/eventProcessor.ts b/backend/src/services/eventProcessor.ts index 8cf6eebf..6c3b8c6e 100644 --- a/backend/src/services/eventProcessor.ts +++ b/backend/src/services/eventProcessor.ts @@ -218,8 +218,12 @@ export class EventProcessor { ); break; - default: - throw new Error(`Unknown event type: ${event.type}`); + default: { + const correlationPrefix = correlationId ? `[${correlationId}] ` : ""; + // Unknown event types must fail loudly instead of being silently + // dropped, so callers can surface the error without losing the event. + throw new Error(`${correlationPrefix}Unknown event type: ${event.type}`); + } } this.eventLog.push({ seq, id: eventId, type: event.type, timestamp: event.timestamp, correlationId: correlationId || undefined }); diff --git a/backend/src/services/eventValidator.ts b/backend/src/services/eventValidator.ts index ebe3d73c..210a3d84 100644 --- a/backend/src/services/eventValidator.ts +++ b/backend/src/services/eventValidator.ts @@ -90,6 +90,9 @@ export interface EventBatchValidationResult { } export function validateEvent(event: unknown): EventValidationResult { + // INVARIANT: every event is validated against the discriminated Soroban + // schema — unknown types, missing fields, and malformed payloads always + // reject, and error messages never echo raw payload contents. const result = SorobanEventSchema.safeParse(event); if (result.success) { diff --git a/backend/src/services/webhookQueueService.ts b/backend/src/services/webhookQueueService.ts index 50f1cc81..844928f9 100644 --- a/backend/src/services/webhookQueueService.ts +++ b/backend/src/services/webhookQueueService.ts @@ -74,40 +74,49 @@ class WebhookQueueService { } enqueue(type: string, payload?: unknown): WebhookEvent { - const stats = webhookDeliveryRepo.getStats(); - if (stats.pending + stats.processing >= MAX_CAPACITY) { - webhookDeliveryRepo.incrementOverflow(); - const err = new Error("Webhook queue capacity exceeded"); - (err as any).statusCode = 503; + try { + return this.db.transaction(() => { + // Check current size of pending/processing elements + const rowCount = this.db + .prepare("SELECT COUNT(*) as count FROM webhook_queue WHERE status IN ('pending', 'processing')") + .get().count; + + if (rowCount >= MAX_CAPACITY) { + const err = new Error("Webhook queue capacity exceeded"); + (err as any).statusCode = 503; + throw err; + } + + const id = ulid(); + const enqueuedAt = new Date().toISOString(); + const event: WebhookEvent = { + id, + type, + payload, + enqueuedAt, + status: "pending", + }; + + this.db + .prepare(` + INSERT INTO webhook_queue (id, type, payload, status, enqueued_at) + VALUES (?, ?, ?, ?, ?) + `) + .run(id, type, JSON.stringify(payload ?? null), "pending", enqueuedAt); + + return event; + })(); + } catch (err) { + if ((err as any)?.statusCode === 503) { + // The transaction above rolls back on overflow, which would also roll + // back an in-transaction counter update. Persist the overflow metric + // outside the failed transaction so it is never lost. + this.db + .prepare("UPDATE queue_metadata SET value = value + 1 WHERE key = 'overflow_count'") + .run(); + } throw err; } - - const delivery = webhookDeliveryRepo.create({ - eventType: type, - payload, - }); - return deliveryToEvent(delivery); - } - - enqueueWithSubscriber( - type: string, - payload: unknown, - subscriberId: string - ): WebhookEvent { - const stats = webhookDeliveryRepo.getStats(); - if (stats.pending + stats.processing >= MAX_CAPACITY) { - webhookDeliveryRepo.incrementOverflow(); - const err = new Error("Webhook queue capacity exceeded"); - (err as any).statusCode = 503; - throw err; - } - - const delivery = webhookDeliveryRepo.create({ - eventType: type, - payload, - subscriberId, - }); - return deliveryToEvent(delivery); } markSuccess(id: string): boolean { diff --git a/backend/src/tests/api-key-rotation-integration.test.ts b/backend/src/tests/api-key-rotation-integration.test.ts index 918af216..4c5c0910 100644 --- a/backend/src/tests/api-key-rotation-integration.test.ts +++ b/backend/src/tests/api-key-rotation-integration.test.ts @@ -11,10 +11,11 @@ describe('API Key Rotation Endpoint (Integration)', () => { let adminId: string; let superAdminKey: string; - const TEST_DB_DIR = path.resolve(__dirname, '../../../.data'); + const TEST_DB_DIR = path.resolve(__dirname, '../../.data'); const TEST_DB_PATH = path.join(TEST_DB_DIR, `test-rotation-int-${crypto.randomUUID()}.db`); beforeAll(async () => { + fs.mkdirSync(TEST_DB_DIR, { recursive: true }); process.env.DATABASE_PATH = TEST_DB_PATH; fs.mkdirSync(TEST_DB_DIR, { recursive: true }); closeDatabase(); diff --git a/backend/src/tests/api-key-store.test.ts b/backend/src/tests/api-key-store.test.ts index 22d63ca6..adec2a09 100644 --- a/backend/src/tests/api-key-store.test.ts +++ b/backend/src/tests/api-key-store.test.ts @@ -38,6 +38,8 @@ beforeAll(() => { created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT, + prev_signing_secret_hash TEXT, + prev_secret_expires_at TEXT, revoked INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, prev_signing_secret_hash TEXT, diff --git a/backend/src/tests/backfill.service.test.ts b/backend/src/tests/backfill.service.test.ts index b31ed95f..f2ef11a4 100644 --- a/backend/src/tests/backfill.service.test.ts +++ b/backend/src/tests/backfill.service.test.ts @@ -13,6 +13,9 @@ const backfillStatementCache = new Map(); jest.mock("../lib/database", () => ({ getDatabase: () => mockDb, + // backfillService reads through the prepared-statement cache; route it to + // the same per-test in-memory database (resolved lazily at call time). + getPreparedStatement: (sql: string) => mockDb.prepare(sql), closeDatabase: jest.fn(), getPreparedStatement: (sql: string) => { if (!backfillStatementCache.has(sql)) { diff --git a/backend/src/tests/helpers/openapi-loader.ts b/backend/src/tests/helpers/openapi-loader.ts index cf3beb3b..1d4aa12a 100644 --- a/backend/src/tests/helpers/openapi-loader.ts +++ b/backend/src/tests/helpers/openapi-loader.ts @@ -402,9 +402,12 @@ function buildPathParams( const placeholders = [...pathTemplate.matchAll(/\{(\w+)\}/g)].map((m) => m[1]); if (placeholders.length === 0) return []; - // Gather parameter definitions from the path-item level - const paramDefs: Array<{ name: string; in: string; example?: unknown }> = + // Gather parameter definitions from the path-item level and the + // operation level (OpenAPI allows parameters at either level; this spec + // declares path parameters on the operation). + const pathLevelParams = (pathItem["parameters"] as Array<{ name: string; in: string; example?: unknown }> | undefined) ?? []; + const paramDefs: Array<{ name: string; in: string; example?: unknown }> = pathLevelParams; return placeholders.map((name) => { const def = paramDefs.find((p) => p.in === "path" && p.name === name); diff --git a/backend/src/tests/migration-integration.test.ts b/backend/src/tests/migration-integration.test.ts index 780cc928..c9fd7b79 100644 --- a/backend/src/tests/migration-integration.test.ts +++ b/backend/src/tests/migration-integration.test.ts @@ -1,8 +1,37 @@ +import * as path from "path"; +import * as fs from "fs"; +import * as crypto from "crypto"; import { getDatabase, closeDatabase } from "../lib/database"; import { runMigrations, verifyAppliedChecksums, loadMigrationsFromFS, getAppliedVersions, isDatabaseInitialized } from "../lib/migrations/runner"; import { MigrationPolicy, migrateCommand, migrateDownCommand } from "../lib/migrations/policy"; describe("Migration Integration", () => { + // Isolate this suite from any state persisted in the shared dev database: + // these assertions assume a database with no applied migrations. + const TEST_DB_DIR = path.resolve(__dirname, "../../.data"); + const TEST_DB_PATH = path.join(TEST_DB_DIR, `test-mig-integration-${crypto.randomUUID()}.db`); + const ORIGINAL_DATABASE_PATH = process.env.DATABASE_PATH; + + beforeAll(() => { + fs.mkdirSync(TEST_DB_DIR, { recursive: true }); + process.env.DATABASE_PATH = TEST_DB_PATH; + closeDatabase(); + }); + + afterAll(() => { + closeDatabase(); + if (ORIGINAL_DATABASE_PATH === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = ORIGINAL_DATABASE_PATH; + } + try { + fs.unlinkSync(TEST_DB_PATH); + } catch { + // best-effort cleanup + } + }); + beforeEach(() => { closeDatabase(); }); diff --git a/backend/src/tests/migration-runner-applied-versions.test.ts b/backend/src/tests/migration-runner-applied-versions.test.ts new file mode 100644 index 00000000..ae6f2e04 --- /dev/null +++ b/backend/src/tests/migration-runner-applied-versions.test.ts @@ -0,0 +1,402 @@ +/** + * Failure-boundary coverage for `getAppliedVersions` (lib/migrations/runner.ts). + * + * Scenarios covered: + * - Success: valid ledger reads with a deterministic output contract + * (ascending, de-duplicated, integer versions). + * - Boundary/duplicate inputs: version 0, MAX_SAFE_INTEGER, integer-valued + * strings/numbers, duplicate ledger rows. + * - Invalid/stale data: malformed rows and corrupt version cells reject with + * a typed error naming the row index, never a partial list. + * - Connection/permission states: handle acquisition failures (e.g. EACCES / + * SQLITE_CANTOPEN), missing handles, and ledger bootstrap failures. + * - Retry states: transient SQLITE_BUSY/LOCKED failures are flagged + * `retryable`, sanitized messages never leak raw payloads, and retries + * recover without inconsistent results. + * - Concurrency: parallel invocations yield identical results with idempotent + * bootstrap. + * - Regression: legacy `prepare`-only adapters, nullish row results, and + * `isDatabaseInitialized` caller compatibility. + */ + +jest.mock("../lib/database", () => ({ + getDatabase: jest.fn(), + closeDatabase: jest.fn(), +})); + +import Database from "better-sqlite3"; +import { getDatabase } from "../lib/database"; +import { + getAppliedVersions, + isDatabaseInitialized, + MigrationStateReadError, +} from "../lib/migrations/runner"; +import type { DatabaseClient } from "../lib/migrations/runner"; +import { MigrationErrorCodes } from "../lib/migrations/types"; + +const mockedGetDatabase = getDatabase as unknown as jest.Mock; + +type FakeDbOptions = { + /** Value returned by `prepare(...).all()`. Omitted/`undefined` means "no rows". */ + rows?: unknown; + /** Thrown by `exec` (bootstrap / permission failures). */ + execError?: unknown; + /** Thrown by `prepare` (schema / SQL failures). */ + prepareError?: unknown; + /** Thrown by `all` (execution / lock failures). */ + allError?: unknown; + /** Build an adapter without `exec` (legacy callers in this repo do this). */ + withoutExec?: boolean; +}; + +function createFakeDb(options: FakeDbOptions = {}) { + const exec = jest.fn((_sql: string) => { + if (options.execError !== undefined) throw options.execError; + }); + const all = jest.fn((): unknown => { + if (options.allError !== undefined) throw options.allError; + return options.rows; + }); + const prepare = jest.fn((_sql: string) => { + if (options.prepareError !== undefined) throw options.prepareError; + return { all, get: jest.fn(), run: jest.fn() }; + }); + const db = ( + options.withoutExec + ? { prepare, transaction: jest.fn() } + : { exec, prepare, transaction: jest.fn() } + ) as unknown as DatabaseClient; + return { db, exec, prepare, all }; +} + +/** Await a promise expected to reject; assert its error type and return it. */ +async function captureError(promise: Promise): Promise { + try { + await promise; + } catch (err) { + expect(err).toBeInstanceOf(MigrationStateReadError); + return err as MigrationStateReadError; + } + throw new Error("expected promise to reject with MigrationStateReadError"); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockedGetDatabase.mockReset(); +}); + +describe("getAppliedVersions: valid input and output contract", () => { + test("returns ascending unique versions regardless of adapter row order", async () => { + const { db } = createFakeDb({ rows: [{ version: 3 }, { version: 1 }, { version: 2 }] }); + await expect(getAppliedVersions(db)).resolves.toEqual([1, 2, 3]); + }); + + test("returns [] for an empty ledger", async () => { + const { db } = createFakeDb({ rows: [] }); + await expect(getAppliedVersions(db)).resolves.toEqual([]); + }); + + test("bootstraps the ledger table before querying (fresh-database determinism)", async () => { + const { db, exec, prepare } = createFakeDb({ rows: [] }); + await getAppliedVersions(db); + expect(exec).toHaveBeenCalledTimes(1); + expect(String(exec.mock.calls[0][0])).toContain("CREATE TABLE IF NOT EXISTS _migrations"); + expect(exec.mock.invocationCallOrder[0]).toBeLessThan(prepare.mock.invocationCallOrder[0]); + }); + + test("does not consult the default connection when a db is injected", async () => { + const { db } = createFakeDb({ rows: [] }); + await getAppliedVersions(db); + expect(mockedGetDatabase).not.toHaveBeenCalled(); + }); +}); + +describe("getAppliedVersions: boundary and duplicate inputs", () => { + test("accepts version 0 (lower boundary, filename regex allows v000)", async () => { + const { db } = createFakeDb({ rows: [{ version: 0 }] }); + await expect(getAppliedVersions(db)).resolves.toEqual([0]); + }); + + test("accepts Number.MAX_SAFE_INTEGER (upper boundary)", async () => { + const { db } = createFakeDb({ rows: [{ version: Number.MAX_SAFE_INTEGER }] }); + await expect(getAppliedVersions(db)).resolves.toEqual([Number.MAX_SAFE_INTEGER]); + }); + + test("accepts integer-valued numeric strings from driver serialization", async () => { + const { db } = createFakeDb({ rows: [{ version: "7" }, { version: "003" }] }); + await expect(getAppliedVersions(db)).resolves.toEqual([3, 7]); + }); + + test("accepts integer-valued numbers (5.0 === 5)", async () => { + const { db } = createFakeDb({ rows: [{ version: 5.0 }] }); + await expect(getAppliedVersions(db)).resolves.toEqual([5]); + }); + + test("de-duplicates repeated ledger rows instead of returning dups", async () => { + const { db } = createFakeDb({ + rows: [{ version: 2 }, { version: 2 }, { version: 1 }, { version: 1 }], + }); + await expect(getAppliedVersions(db)).resolves.toEqual([1, 2]); + }); +}); + +describe("getAppliedVersions: invalid ledger data rejects deterministically", () => { + const invalidCases: Array<[label: string, rows: unknown[]]> = [ + ["a null row", [null]], + ["an array row", [[1]]], + ["a row missing the version property", [{}]], + ["a null version", [{ version: null }]], + ["a fractional version", [{ version: 1.5 }]], + ["a negative version", [{ version: -1 }]], + ["an unsafe-integer version", [{ version: Number.MAX_SAFE_INTEGER + 2 }]], + ["a non-numeric string version", [{ version: "abc" }]], + ["an empty-string version", [{ version: "" }]], + ["a whitespace-only version", [{ version: " " }]], + ["a boolean version", [{ version: true }]], + ["an object version", [{ version: { nested: true } }]], + ]; + + for (const [label, rows] of invalidCases) { + test(`rejects ${label} with a typed error naming the row index`, async () => { + const { db } = createFakeDb({ rows }); + const err = await captureError(getAppliedVersions(db)); + expect(err.name).toBe("MigrationStateReadError"); + expect(err.code).toBe(MigrationErrorCodes.MIGRATION_STATE_READ_FAILED); + expect(err.message).toContain("index 0"); + expect(err.retryable).toBe(false); + }); + } + + test("a stale/corrupt row at a later index still rejects the whole read (no partial list)", async () => { + const { db } = createFakeDb({ rows: [{ version: 1 }, { version: "stale" }] }); + const err = await captureError(getAppliedVersions(db)); + expect(err.message).toContain("index 1"); + }); + + test("structured row payloads are never echoed into the error message", async () => { + const secret = "sk_live_must_not_leak"; + const { db } = createFakeDb({ rows: [{ version: { token: secret } }] }); + const err = await captureError(getAppliedVersions(db)); + expect(err.message).not.toContain(secret); + expect(err.message).toContain("index 0"); + }); + + test("a non-array row set rejects instead of crashing on .map", async () => { + const { db } = createFakeDb({ rows: { version: 1 } }); + const err = await captureError(getAppliedVersions(db)); + expect(err.message).toContain("expected an array of rows"); + }); +}); + +describe("getAppliedVersions: connection, permission, and driver failure states", () => { + test("rejects with a typed error when the connection cannot be acquired", async () => { + const cantOpen = Object.assign(new Error("unable to open database file"), { + code: "SQLITE_CANTOPEN", + }); + mockedGetDatabase.mockImplementationOnce(() => { + throw cantOpen; + }); + const err = await captureError(getAppliedVersions()); + expect(err.message).toContain("unable to open database file"); + expect(err.cause).toBe(cantOpen); + expect(err.retryable).toBe(false); + }); + + test("tolerates non-Error throws from the connection layer", async () => { + mockedGetDatabase.mockImplementationOnce(() => { + throw "boom"; + }); + const err = await captureError(getAppliedVersions()); + expect(err.message).toContain("boom"); + expect(err.retryable).toBe(false); + }); + + test("rejects deterministically when the connection layer returns no handle", async () => { + mockedGetDatabase.mockReturnValue(undefined); + const err = await captureError(getAppliedVersions()); + expect(err.message).toContain("no database handle available"); + expect(err.retryable).toBe(false); + }); + + test("permission failure during ledger bootstrap rejects (never a silent empty list)", async () => { + const { db } = createFakeDb({ + rows: [], + execError: new Error("attempt to write a readonly database"), + }); + const err = await captureError(getAppliedVersions(db)); + expect(err.message).toContain("Failed to ensure _migrations ledger exists"); + expect(err.message).toContain("attempt to write a readonly database"); + expect(err.retryable).toBe(false); + }); + + test("statement preparation failures reject with the cause preserved", async () => { + const prepareFailure = new Error("no such table: _migrations"); + const { db } = createFakeDb({ prepareError: prepareFailure }); + const err = await captureError(getAppliedVersions(db)); + expect(err.message).toContain("Failed to read applied migration ledger"); + expect(err.cause).toBe(prepareFailure); + }); + + test("transient SQLITE_BUSY failures are flagged retryable", async () => { + const busy = Object.assign(new Error("database is locked"), { + code: "SQLITE_BUSY", + errcode: 5, + }); + const { db } = createFakeDb({ rows: [], allError: busy }); + const err = await captureError(getAppliedVersions(db)); + expect(err.retryable).toBe(true); + expect(err.code).toBe(MigrationErrorCodes.MIGRATION_STATE_READ_FAILED); + }); + + test("extended SQLite busy errcodes (0x105 busy-snapshot) are flagged retryable", async () => { + const busySnapshot = Object.assign(new Error("sqlite busy snapshot"), { errcode: 0x105 }); + const { db } = createFakeDb({ rows: [], allError: busySnapshot }); + const err = await captureError(getAppliedVersions(db)); + expect(err.retryable).toBe(true); + }); + + test("message-only lock detection works for adapters without a code field", async () => { + const { db } = createFakeDb({ rows: [], allError: { message: "database is locked" } }); + const err = await captureError(getAppliedVersions(db)); + expect(err.message).toContain("database is locked"); + expect(err.retryable).toBe(true); + }); + + test("driver messages are sanitized: single-line and length-capped, cause untouched", async () => { + const noisy = new Error(`SQLITE_CORRUPT: malformed header\nstack line\n${"x".repeat(500)}`); + const { db } = createFakeDb({ rows: [], allError: noisy }); + const err = await captureError(getAppliedVersions(db)); + expect(err.message).not.toContain("\n"); + expect(err.message.length).toBeLessThan(300); + expect(err.cause).toBe(noisy); + }); +}); + +describe("getAppliedVersions: retry and concurrency safety", () => { + test("repeated calls are idempotent (same result, no state change)", async () => { + const { db } = createFakeDb({ rows: [{ version: 1 }, { version: 2 }] }); + const first = await getAppliedVersions(db); + const second = await getAppliedVersions(db); + expect(second).toEqual(first); + expect(first).toEqual([1, 2]); + }); + + test("concurrent invocations all resolve to identical results", async () => { + const { db, exec } = createFakeDb({ rows: [{ version: 2 }, { version: 1 }] }); + const results = await Promise.all( + Array.from({ length: 5 }, () => getAppliedVersions(db)) + ); + expect(results).toEqual([ + [1, 2], + [1, 2], + [1, 2], + [1, 2], + [1, 2], + ]); + // Bootstrap runs per call but is idempotent DDL (CREATE TABLE IF NOT EXISTS). + expect(exec).toHaveBeenCalledTimes(5); + expect(new Set(exec.mock.calls.map((c) => String(c[0]))).size).toBe(1); + }); + + test("a transient failure rejects, and a retry after the lock clears succeeds", async () => { + const options: FakeDbOptions = { + rows: [{ version: 1 }], + allError: Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" }), + }; + const { db } = createFakeDb(options); + const err = await captureError(getAppliedVersions(db)); + expect(err.retryable).toBe(true); + // Lock released: the same adapter now serves the read with no state drift. + options.allError = undefined; + await expect(getAppliedVersions(db)).resolves.toEqual([1]); + }); +}); + +describe("getAppliedVersions: regression and caller compatibility", () => { + test("legacy prepare-only adapters keep working (existing callers)", async () => { + const { db } = createFakeDb({ rows: [{ version: 1 }, { version: 2 }], withoutExec: true }); + await expect(getAppliedVersions(db)).resolves.toEqual([1, 2]); + }); + + test("legacy nullish all() results are treated as an empty ledger", async () => { + const nullRows = createFakeDb({ rows: null }); + await expect(getAppliedVersions(nullRows.db)).resolves.toEqual([]); + const undefinedRows = createFakeDb(); + await expect(getAppliedVersions(undefinedRows.db)).resolves.toEqual([]); + }); + + test("fresh database: bootstrap prevents 'no such table' and yields []", async () => { + // Stateful adapter modeling better-sqlite3 on an uninitialized database. + let tableExists = false; + const db = { + exec: jest.fn((_sql: string) => { + tableExists = true; + }), + prepare: jest.fn((_sql: string) => ({ + all: jest.fn((): unknown => { + if (!tableExists) throw new Error("no such table: _migrations"); + return []; + }), + })), + transaction: jest.fn(), + } as unknown as DatabaseClient; + + await expect(getAppliedVersions(db)).resolves.toEqual([]); + expect(tableExists).toBe(true); + }); + + test("isDatabaseInitialized stays true when versions exist", async () => { + const { db } = createFakeDb({ rows: [{ version: 1 }] }); + await expect(isDatabaseInitialized(db)).resolves.toBe(true); + }); + + test("isDatabaseInitialized stays false on an empty ledger", async () => { + const { db } = createFakeDb({ rows: [] }); + await expect(isDatabaseInitialized(db)).resolves.toBe(false); + }); + + test("real in-memory SQLite: fresh database reads [] and applied rows are returned", async () => { + const DatabaseCtor = Database as any; + const sqlite = new DatabaseCtor(":memory:"); + try { + const db = { + exec: (sql: string): void => { + sqlite.exec(sql); + }, + prepare: (sql: string) => sqlite.prepare(sql), + transaction: (fn: () => void): void => { + fn(); + }, + } as unknown as DatabaseClient; + + // Fresh (uninitialized) database: deterministic empty ledger, not a throw. + await expect(getAppliedVersions(db)).resolves.toEqual([]); + + const insert = (version: number, name: string) => + sqlite + .prepare( + "INSERT INTO _migrations (version, name, checksum, applied_at, duration_ms, author, meta) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .run( + version, + name, + `checksum-${version}`, + new Date().toISOString(), + 5, + "test-author", + "{}" + ); + + // Insert out of order: read order must still be deterministic. + insert(2, "second"); + insert(1, "first"); + await expect(getAppliedVersions(db)).resolves.toEqual([1, 2]); + + // Database-level duplicate guard holds; the failed write cannot corrupt reads. + expect(() => insert(1, "duplicate")).toThrow(); + await expect(getAppliedVersions(db)).resolves.toEqual([1, 2]); + } finally { + sqlite.close(); + } + }); +}); diff --git a/backend/src/tests/migration-runner.test.ts b/backend/src/tests/migration-runner.test.ts index cd4100e9..3a00bf8e 100644 --- a/backend/src/tests/migration-runner.test.ts +++ b/backend/src/tests/migration-runner.test.ts @@ -1,43 +1,33 @@ -import { - parseMigrationFilename, - computeChecksum, - verifyAppliedChecksums, - validateMigrationFiles, - getAppliedVersions, - isDatabaseInitialized, -} from "../lib/migrations/runner"; - -const originalFetch = global.fetch; -const originalEnv = { ...process.env }; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -function installFetchMock(handler: (url: string, init?: RequestInit) => Promise) { - const mock = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.toString(); - return handler(url, init); - }); - global.fetch = mock as unknown as typeof fetch; - return mock; -} - -beforeEach(() => { - process.env = { ...originalEnv }; - { - delete process.env.SUPABASE_URL; - delete process.env.SUPABASE_ANON_KEY; - delete process.env.DATABASE_URL; - } +import * as path from "path"; +import * as fs from "fs"; +import * as crypto from "crypto"; +import { parseMigrationFilename, computeChecksum, verifyAppliedChecksums, validateMigrationFiles, getAppliedVersions, isDatabaseInitialized } from "../lib/migrations/runner"; +import { closeDatabase } from "../lib/database"; + +// Isolate this suite from any state persisted in the shared dev database: +// these assertions assume a database with no applied migrations. +const TEST_DB_DIR = path.resolve(__dirname, "../../.data"); +const TEST_DB_PATH = path.join(TEST_DB_DIR, `test-mig-runner-${crypto.randomUUID()}.db`); +const ORIGINAL_DATABASE_PATH = process.env.DATABASE_PATH; + +beforeAll(() => { + fs.mkdirSync(TEST_DB_DIR, { recursive: true }); + process.env.DATABASE_PATH = TEST_DB_PATH; + closeDatabase(); }); afterAll(() => { - global.fetch = originalFetch; - process.env = { ...originalEnv }; + closeDatabase(); + if (ORIGINAL_DATABASE_PATH === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = ORIGINAL_DATABASE_PATH; + } + try { + fs.unlinkSync(TEST_DB_PATH); + } catch { + // best-effort cleanup + } }); describe("Migration Runner Utilities", () => { diff --git a/backend/src/tests/perf/perf.test.ts b/backend/src/tests/perf/perf.test.ts index 26358358..42eca21e 100644 --- a/backend/src/tests/perf/perf.test.ts +++ b/backend/src/tests/perf/perf.test.ts @@ -5,6 +5,9 @@ * provide measurable performance improvements over naive implementations. */ +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; import { getDatabase, getPreparedStatement, closeDatabase, clearStatementCache, getStatementCacheStats } from '../../lib/database'; import { invoiceStore } from '../../services/invoiceStore'; import { db as apiKeyDb } from '../../db/database'; @@ -12,12 +15,31 @@ import { Invoice, InvoiceStatus } from '../../types/contract'; import { ulid } from 'ulid'; describe('Database Performance Tests', () => { - const TEST_DB = ':memory:'; - + // File-backed temp database: SQLite cannot enable WAL on `:memory:` + // databases (they always report journal_mode=memory), so a real file is + // required to verify the WAL pragma invariant. + const TEST_DB = path.resolve(__dirname, '../../../.data', `perf-${crypto.randomUUID()}.db`); + const ORIGINAL_DATABASE_PATH = process.env.DATABASE_PATH; + beforeAll(() => { + fs.mkdirSync(path.dirname(TEST_DB), { recursive: true }); process.env.DATABASE_PATH = TEST_DB; }); + afterAll(() => { + closeDatabase(); + if (ORIGINAL_DATABASE_PATH === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = ORIGINAL_DATABASE_PATH; + } + try { + fs.unlinkSync(TEST_DB); + } catch { + // best-effort cleanup + } + }); + beforeEach(() => { closeDatabase(); clearStatementCache(); @@ -58,6 +80,8 @@ describe('Database Performance Tests', () => { created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT, + prev_signing_secret_hash TEXT, + prev_secret_expires_at TEXT, revoked INTEGER DEFAULT 0, created_by TEXT NOT NULL, prev_signing_secret_hash TEXT, diff --git a/backend/src/tests/rbac.tokens.test.ts b/backend/src/tests/rbac.tokens.test.ts index fd2811b6..a4859b46 100644 --- a/backend/src/tests/rbac.tokens.test.ts +++ b/backend/src/tests/rbac.tokens.test.ts @@ -37,6 +37,8 @@ describe('rbac middleware (API-key backed)', () => { created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT, + prev_signing_secret_hash TEXT, + prev_secret_expires_at TEXT, revoked INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, prev_signing_secret_hash TEXT, diff --git a/backend/src/tests/reconciliation.test.ts b/backend/src/tests/reconciliation.test.ts index df5d1917..abbbebd0 100644 --- a/backend/src/tests/reconciliation.test.ts +++ b/backend/src/tests/reconciliation.test.ts @@ -28,6 +28,17 @@ jest.mock("../services/replayService", () => ({ derivedTableStore: { listInvoices: jest.fn() }, })); +// Route the drift-backfill persistence through a per-test in-memory database +// so these tests never depend on (or mutate) the shared dev database, which +// may not have the backfill tables created. +let mockDb: any; + +jest.mock("../lib/database", () => ({ + getDatabase: () => mockDb, + getPreparedStatement: (sql: string) => mockDb.prepare(sql), + closeDatabase: jest.fn(), +})); + describe("ReconciliationWorker", () => { beforeEach(() => { // Create a fresh in-memory database with required tables @@ -58,6 +69,32 @@ describe("ReconciliationWorker", () => { (ReconciliationWorker as any).reports = []; (ReconciliationWorker as any).isRunning = false; + // Fresh in-memory database with the tables the backfill path writes to. + mockDb = new (Database as any)(":memory:"); + mockDb.exec(` + CREATE TABLE IF NOT EXISTS backfill_progress ( + id TEXT PRIMARY KEY, + audit_id INTEGER, + run_id TEXT NOT NULL, + last_processed_id TEXT, + remaining_count INTEGER NOT NULL, + total_count INTEGER NOT NULL, + status TEXT NOT NULL CHECK(status IN ('running','paused','completed','failed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS backfill_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + event_type TEXT NOT NULL, + actor TEXT NOT NULL, + metadata TEXT DEFAULT '{}', + invoice_id TEXT + ); + `); + // Wire mock data sources (rpcClient.call as jest.Mock).mockResolvedValue(MockDataProviders.getOnChainInvoices()); (derivedTableStore.listInvoices as jest.Mock).mockResolvedValue(MockDataProviders.getIndexedInvoices()); @@ -68,7 +105,6 @@ describe("ReconciliationWorker", () => { mockDb.close(); mockDb = null; } - statementCache.clear(); }); test("should detect drift accurately", async () => { diff --git a/backend/src/tests/shutdown.test.ts b/backend/src/tests/shutdown.test.ts index 143b7af7..8e9fa753 100644 --- a/backend/src/tests/shutdown.test.ts +++ b/backend/src/tests/shutdown.test.ts @@ -71,8 +71,7 @@ jest.mock('../services/statusService', () => ({ // Imports — after mocks // --------------------------------------------------------------------------- import http from 'http'; -import path from 'path'; -import crypto from 'crypto'; +import Database from 'better-sqlite3'; import { createShutdownHandler, resetShuttingDown, @@ -639,6 +638,41 @@ describe('WebhookQueueService.flush (real implementation)', () => { return WebhookQueueService.getInstance(); } + // The suite runs with ../lib/database mocked, but the real service needs a + // real connection: back the mock with an in-memory SQLite instance that has + // the queue schema so flush/enqueue/mark run against actual SQL. + let queueDb: any; + + beforeAll(() => { + queueDb = new (Database as any)(':memory:'); + queueDb.exec(` + CREATE TABLE IF NOT EXISTS webhook_queue ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('pending','processing','success','failed')), + enqueued_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + queueDb.exec(` + CREATE TABLE IF NOT EXISTS queue_metadata ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL DEFAULT 0 + ) + `); + queueDb.exec("INSERT OR IGNORE INTO queue_metadata (key, value) VALUES ('overflow_count', 0)"); + (getDatabase as jest.Mock).mockReturnValue(queueDb); + }); + + beforeEach(() => { + queueDb.exec('DELETE FROM webhook_queue'); + }); + + afterAll(() => { + (getDatabase as jest.Mock).mockReset(); + if (queueDb) queueDb.close(); + }); + it('returns an empty array when the queue is empty', () => { const q = freshQueue(); expect(q.flush()).toEqual([]); @@ -721,6 +755,22 @@ describe('WebhookQueueService.flush (real implementation)', () => { expect(q.flush()).toEqual([]); }); + it('returns correct events for every enqueue until capacity is reached', () => { + const q = freshQueue(); + q.enqueue('first'); + q.enqueue('second'); + q.enqueue('third'); + q.enqueue('fourth'); + + // The durable queue retains every event until flushed (overflow past the + // 5000-event capacity rejects with 503 — covered in webhookQueue.persist). + expect(q.getDepth()).toBe(4); + + const flushed = q.flush(); + expect(flushed).toHaveLength(4); + expect(q.getDepth()).toBe(0); + }); + it('queue is reusable after flush', () => { const q = freshQueue(); q.enqueue('before-flush'); diff --git a/backend/src/tests/webhookQueue.persist.test.ts b/backend/src/tests/webhookQueue.persist.test.ts index 89c92bfd..b5236535 100644 --- a/backend/src/tests/webhookQueue.persist.test.ts +++ b/backend/src/tests/webhookQueue.persist.test.ts @@ -1,7 +1,7 @@ import path from "path"; import crypto from "crypto"; import { getDatabase, closeDatabase } from "../lib/database"; -import { WebhookQueueService, webhookQueueService } from "../services/webhookQueueService"; +import { WebhookQueueService } from "../services/webhookQueueService"; // --------------------------------------------------------------------------- // Test database lifecycle – isolated temp file per run @@ -10,6 +10,11 @@ import { WebhookQueueService, webhookQueueService } from "../services/webhookQue const TEST_DB_DIR = path.resolve(__dirname, "../../.data"); const TEST_DB_PATH = path.join(TEST_DB_DIR, `test-webhook-queue-${crypto.randomUUID()}.db`); +// Bound to the current instance in beforeEach: the module-level export is +// created at import time (before DATABASE_PATH points at this test's DB) and +// would otherwise hold a stale, closed connection. +let webhookQueueService: WebhookQueueService; + beforeAll(() => { process.env.DATABASE_PATH = TEST_DB_PATH; closeDatabase(); @@ -59,6 +64,7 @@ beforeEach(() => { const conn = getDatabase(); conn.exec("DELETE FROM webhook_deliveries"); WebhookQueueService.resetInstance(); + webhookQueueService = WebhookQueueService.getInstance(); }); describe("Durable Webhook Queue Resilience Tests", () => { diff --git a/backend/tests/eventProcessor.validation.test.ts b/backend/tests/eventProcessor.validation.test.ts index b6e06516..5bb8a8d7 100644 --- a/backend/tests/eventProcessor.validation.test.ts +++ b/backend/tests/eventProcessor.validation.test.ts @@ -1,6 +1,6 @@ import express from 'express'; import request from 'supertest'; -import { mkdtemp, rm } from 'fs/promises'; +import { mkdtemp, rm, mkdir } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { afterEach, describe, it, expect, jest } from '@jest/globals'; @@ -156,6 +156,9 @@ describe('POST /events validation and idempotency', () => { ) { tempDir = await mkdtemp(join(tmpdir(), 'quicklendx-events-')); process.chdir(tempDir); + // getDatabase() resolves its file relative to cwd; the temp directory + // needs the data folder to exist before routes are imported. + await mkdir(join(tempDir, '.data'), { recursive: true }); jest.resetModules(); process.env.DATABASE_PATH = ':memory:'; jest.doMock('../src/services/notificationService', () => ({