import type { Repositories } from './types.js'; /** * # Adapter conformance suite * * A persistence adapter for `@urbicon-ui/auth` is only safe if its claim * operations are genuinely atomic and its scoped mutations are genuinely * scoped. Those guarantees cannot be eyeballed from an implementation — a * read-then-write that looks fine sequentially silently double-spends a * single-use token under concurrency. This suite turns the interface contract * (`types.ts`) into executable, adapter-agnostic tests so that **any** adapter * — the shipped Prisma/in-memory ones or a community Drizzle/Kysely/raw-SQL * one — can prove it upholds the contract before shipping. * * ## Usage * * In your adapter's test file: * * ```ts * import { describeRepositoryConformance } from '@urbicon-ui/auth/server/adapters/conformance'; * import { createMyAdapter } from './my-adapter'; * * describeRepositoryConformance('my-adapter', { * role: 'USER', * capabilities: { * refreshToken: true, * passkey: true, * notification: true, * pushSubscription: true, * notificationPreference: true, * backupCode: true, * federatedAccount: true * }, * setup: () => createMyAdapter(freshTestDatabase()) * }); * ``` * * `setup()` MUST return a clean, isolated repository set on every call — the * suite calls it once per check and assumes no shared state between checks * (wipe the schema, use a fresh transaction, or hand back new in-memory maps). * `capabilities` gates the optional-repository checks: a check whose required * repos you do not declare is reported as skipped rather than failing. List * every optional repository and set the ones you do not implement to `false` — * an omitted key reads as "not implemented" and silently drops its checks. The * suite title states how many checks ran and which repositories were left * undeclared, so a truncated list is visible in the output rather than only in * its absence. * * ## Before the first run: your id columns * * Ids are opaque strings to this package, and the checks hold you to that. Most * of them feed your adapter ids it handed back itself, but seven pass a * deliberately malformed one (`'not-an-id'`) and require a miss — `null`, * `false`, no-op. Six of those seven are capability-gated, so a harness * declaring no optional repositories exercises exactly one of them, and a green * default run says correspondingly little. * * A column that parses its input instead of storing it — a native `uuid`, an * integer key — cannot miss on such a value. It fails while parsing the literal, * on reads as much as on writes, so the check reports that error rather than the * column type behind it. (Postgres raises SQLSTATE 22P02. Other engines word it * differently, which is why an adapter on another engine cannot reuse the * Postgres wording.) * * A native id type is allowed — the adapter guide says so explicitly — but it * moves work onto the adapter: catch that one error, on that one argument, and * return the miss, while every other database error keeps propagating. The * shipped Prisma adapter does exactly that in `idSafeClient`, though only on a * driver adapter; on the older Rust engine (v5/v6) there is no translation and * the adapter satisfies the rule itself. The full contract, including what it * deliberately does *not* cover (inserts), is the `Ids are opaque strings` * section at the top of `types.ts`. * * ## Contract sentences this suite deliberately leaves unchecked * * Four of them, each because the value a violation would corrupt is never read * back inside this package — so it cannot reach a user through it, and a check * would pin one adapter's behaviour on every other for no gain. Listed so an * audit does not rediscover them as gaps: * * - `listActiveByUser`'s newest-first ordering. Both callers are order-blind: * `handlers/sessions.ts` collapses the rows per family and sorts the result * itself, `rotateRefreshToken` only asks whether a family is among them — so * the repository's order never reaches a response. * - `recordFailedLogin` **without** a lock (bump the counter only). That path * is reachable — `config.lockout: null` normalizes to `undefined` at the * call — but the same flag gates the only reader * (`getFailedLoginAttempts`), so with lockout off the counter is written and * nothing ever looks at it. * - `BackupCodeRepository.createMany`'s "MUST NOT deduplicate". The one caller * runs `deleteAll` on the line above, so the batch is always clean. * - `setTotpSecret` forcing `totpEnabled: false` when 2FA is **already** on. * Both 2FA setup handlers answer `two_factor_already_enabled` before the * write is reached. The off → stage → enable → disable path *is* checked. * * ## Test runners * * This module is runner-agnostic: it takes `describe`/`it`/`expect` from the * caller instead of importing them, so it works under any runner whose * assertion API matches — vitest and `bun:test` do as they are. * * `@urbicon-ui/auth/server/adapters/conformance` is the vitest-wired entry and * needs nothing extra. Under another runner, import this module and hand it the * runner: * * ```ts * import { describe, expect, it } from 'bun:test'; * import { describeRepositoryConformance } from '@urbicon-ui/auth/server/adapters/conformance-core'; * * describeRepositoryConformance('my-adapter', harness, { runner: { describe, it, expect } }); * ``` * * jest needs one adapter line: its `expect` throws on the second (message) * argument the checks pass, so drop it — `expect: (actual) => expect(actual)`. * The checks still assert the same thing; only the failure message is thinner. */ /** * The slice of a test runner the suite uses: `describe`, `it` (with `.skip`) * and a chai-style `expect(actual, message)`. vitest and `bun:test` satisfy it * directly; jest's `expect` rejects a second argument and needs a one-line * wrapper that drops the message. */ export interface ConformanceRunner { describe: (name: string, fn: () => void) => void; it: ((name: string, fn: () => Promise | void) => void) & { skip: (name: string, fn?: () => Promise | void) => void; }; /** The matcher chain is the runner's; typing it here would pin one runner's surface. */ expect: (actual: any, message?: string) => any; } /** * Register the runner the checks assert through. The vitest entry calls this * for you; other runners pass `options.runner` to * {@link describeRepositoryConformance} (which forwards here) or call it * directly before running checks by hand. */ export declare function setConformanceRunner(runner: ConformanceRunner): void; /** * Optional repositories an adapter may implement; gates the matching checks. * * Every key is optional so a harness can grow into the list, which makes an * omission indistinguishable from a `false`. Declare all seven — the ones you * do not implement as `false` — and read the suite title, which names whatever * stayed undeclared. */ export interface ConformanceCapabilities { refreshToken?: boolean; passkey?: boolean; notification?: boolean; pushSubscription?: boolean; notificationPreference?: boolean; backupCode?: boolean; federatedAccount?: boolean; } export interface ConformanceHarness { /** * Produce a fresh, fully-isolated repository set for a single check. Called * once per check — must not share mutable state across calls. */ setup(): Repositories | Promise>; /** Optional cleanup run after each check (drop the schema, close handles). */ teardown?(repos: Repositories): void | Promise; /** The role value used when the suite seeds users. */ role: R; /** * Optional repositories this adapter implements (others' checks skip). * List all seven, `false` for the ones you do not implement — see * {@link ConformanceCapabilities}. */ capabilities?: ConformanceCapabilities; } export interface ConformanceCheck { /** Stable, human-readable name — also the test title. */ readonly name: string; /** Optional repos this check needs; skipped unless the harness declares them. */ readonly requires: ReadonlyArray; /** Throws (via `expect`) on a contract violation. */ run(harness: ConformanceHarness): Promise; } export declare const conformanceChecks: readonly ConformanceCheck[]; export interface ConformanceOptions { /** * The test runner to assert through. Required unless one was registered * already — which the vitest entry * (`@urbicon-ui/auth/server/adapters/conformance`) does for you. */ runner?: ConformanceRunner; /** Run only these checks (by name). */ only?: string[]; /** Skip these checks (by name) on top of capability gating. */ skip?: string[]; } /** What a run against a given harness will and will not execute. */ export interface ConformanceRunSummary { /** Every check the suite defines. */ readonly total: number; /** Checks that will execute against this harness. */ readonly running: number; /** Checks skipped because their repository was not declared. */ readonly skippedUndeclared: number; /** Checks skipped by `options.only` / `options.skip`. */ readonly skippedByOption: number; /** * The optional repositories some check needs and this harness did not * declare, alphabetically. Set by the capability list alone, so it survives * a release that adds checks — unlike the counts beside it. */ readonly undeclared: readonly (keyof ConformanceCapabilities)[]; } /** * What {@link describeRepositoryConformance} would run for this harness, * without registering anything — the same numbers it puts in the suite title. * Useful in a gate of your own ("no repository may go undeclared"). */ export declare function summarizeConformanceRun(harness: ConformanceHarness, options?: ConformanceOptions): ConformanceRunSummary; /** * Register the full conformance suite for `harness` under a `describe` block. * Capability-gated checks the harness does not declare are reported as skipped. * * The suite title carries the summary — how many of the checks run, and which * repositories were left undeclared. An incomplete capability list is the one * way to pass this suite without it having said anything, and it produces no * failure to notice; the title is what makes it legible in the run output. */ export declare function describeRepositoryConformance(name: string, harness: ConformanceHarness, options?: ConformanceOptions): void;