diff --git a/docs/user-failure-handling-regression.md b/docs/user-failure-handling-regression.md new file mode 100644 index 00000000..6d3db744 --- /dev/null +++ b/docs/user-failure-handling-regression.md @@ -0,0 +1,196 @@ +# User Repository Failure-Handling Regression Suite + +## Overview + +`src/db/repositories/userRepository.regression.test.ts` pins the three explicit +failure / empty-result exits of `UserRepository`: + +| Line | Method | Guard | Pinned error | +| ---- | ------ | ----- | ------------ | +| `userRepository.ts:129` | `createUser` | `result.rows.length === 0` after `INSERT … RETURNING *` | `Error('Failed to create user')` | +| `userRepository.ts:182` | `updateUser` | no updatable fields and `findById` returns `null` | `Error('User not found')` | +| `userRepository.ts:201` | `updateUser` | `result.rows.length === 0` after `UPDATE … RETURNING *` | `Error('Failed to update user')` | + +The suite is **test-only**: no production file is modified, so the public +contract (`Promise` / `Promise` and the existing error +messages/classes) is preserved exactly. The goal is to make silent behaviour +changes — for example turning an empty `RETURNING` set into a success, returning +a partially populated `User`, or swapping a plain `Error` for a +`UniqueConstraintError` — fail loudly in CI. + +## What the suite pins + +### `createUser` — empty `RETURNING` set (line 129) + +* Exact rejection class (`Error`, not a domain subclass) and exact message. +* The guard is keyed on `rows.length`; a contradictory driver + `rowCount: 1` still throws. +* The call never resolves with a fabricated or partial user. +* `rows: [null]` rejects with a `TypeError` instead of returning a user. +* The same input produces the same error on every call. +* The message contains no SQL text, email address or password hash. +* Neighbouring success/boundary paths: full row mapping, five positional + parameters, `startup`/`standard` defaults, `NULL` name, unknown stored tier → + `standard`, empty-string credentials forwarded as parameters, and hostile + email payloads bound (never interpolated) into SQL. + +### `updateUser` — no fields + missing user (line 182) + +* Exact `Error('User not found')` contract when `findById` returns `null`. +* Exactly one `SELECT`, never an `UPDATE`. +* Explicitly-`undefined` optional fields count as “not provided” and take the + same read-only path; they must not trigger a useless/NULL assignment. +* An empty-string `id` is an ordinary lookup miss (no special-casing). +* Deterministic across repeated calls; never resolves with a user. +* Neighbouring success path: an existing user is returned (mapped) without a + write, including `NULL` name → `undefined`, unknown tier → `standard` and a + missing `last_oidc_groups` → `null`. + +### `updateUser` — empty `RETURNING` set (line 201) + +* Exact `Error('Failed to update user')` contract; never a + `UniqueConstraintError`. +* Guard is keyed on `rows.length`; a stale `rowCount: 1` still throws. +* Exactly one `UPDATE`, with the id bound last. +* A `23505` driver error stays mapped to `UniqueConstraintError` with + `field: 'email'`; non-`23505` errors are re-thrown **by identity**. +* The message contains no SQL text, email address or password hash. +* Neighbouring success/boundary paths: every supported field bound in + declaration order, `last_oidc_groups: null` treated as a *provided* value that + clears the column, `[]` serialised to `"[]"`, empty-string email forwarded, + hostile email payloads bound, and full row mapping on success. + +### `updateKycRiskTier` — caller-facing delegation + +* Delegates to `updateUser` with `kyc_risk_tier = $1` and the id last. +* Propagates the pinned `'Failed to update user'` and + `UniqueConstraintError` contracts unchanged, so callers cannot accidentally + observe different failure semantics through the convenience method. + +## Security assumptions + +1. **Fail closed on a lost write.** A routing/replica surprise, an aborted + transaction or a dropped connection can yield an empty `RETURNING` set even + though the statement “ran”. Both guards fail loudly instead of reporting + success to the caller. +2. **No information disclosure through errors.** The three messages are fixed + literals. They are asserted not to contain SQL keywords, the caller-supplied + email or the password hash, so they are safe to log and cannot be used to + probe statement shape or credential material. +3. **Parameterised SQL only.** Caller values are always bound through `$n` + placeholders. The suite feeds payloads such as + `x@y.com'); DROP TABLE users; --` and asserts the payload never appears in + the generated SQL text. +4. **Conflict ≠ lost write.** `23505` is translated to + `UniqueConstraintError(field: 'email')`; an empty result stays a plain + `Error`. Tests assert both directions so a softened guard cannot be masked by + (or confused with) a duplicate-email race. +5. **Error identity is preserved.** Non-`23505` driver errors are re-thrown by + reference. Wrapping or message rewriting would break upstream retry/alerting + classification. +6. **Repository layer is not a validator.** Empty strings, unknown stored tiers + and `NULL` groups are forwarded/normalised as documented; callers + (`RegisterService`, `scim.ts`, `oidcRoute.ts`) own input validation and + normalisation. + +## Abuse and failure-path coverage + +| Scenario | Expected observable behaviour | Covered by | +| -------- | ----------------------------- | ---------- | +| `INSERT` reports no returned row | `Error('Failed to create user')`, no `User` | `createUser` guard block | +| Driver reports `rowCount: 1` with no rows | Still rejects (guard uses `rows.length`) | `createUser` / `updateUser` guard blocks | +| Driver returns `rows: [null]` | `TypeError`, never a fabricated user | `createUser` guard block | +| No fields supplied, user absent | `Error('User not found')`, one `SELECT`, zero `UPDATE` | `updateUser` (line 182) block | +| `UPDATE` matches no row | `Error('Failed to update user')`, no partial user | `updateUser` (line 201) block | +| Duplicate email (`23505`) on update | `UniqueConstraintError`, `field: 'email'` | `updateUser` (line 201) block | +| Non-`23505` driver failure | Same error object re-thrown | `updateUser` (line 201) block | +| Hostile email payload | Bound as a parameter, absent from SQL text | `createUser` / `updateUser` success blocks | +| Unknown stored KYC tier | Normalised to `standard` | mapping blocks | +| Stored `NULL` name / missing groups | `undefined` / `null` respectively | mapping blocks | + +## Deliberately pinned (not changed) + +These are current behaviours the suite documents rather than “fixes”; changing +them would be a separate compatibility decision: + +* The repository performs no validation or normalisation of email/password + inputs. +* `rows: [null]` throws a `TypeError` from `mapUser` (only “never returns a + user” is asserted, not the message). +* `updatePasswordHash` resolves successfully for an unknown id (no + `RETURNING` guard). + +## How to run + +Focused suite: + +```bash +npx jest src/db/repositories/userRepository.regression.test.ts --coverage=false +``` + +Focused suite with the 95 % coverage gate applied to `userRepository.ts` +(the existing contract suite is included so the untouched read methods are +counted too): + +```bash +npm run test:coverage:user-failure +``` + +Surrounding suite (existing repository contract + direct consumers): + +```bash +npx jest --coverage=false \ + src/db/repositories/userRepository.test.ts \ + src/db/repositories/userRepository.regression.test.ts \ + src/services/kycRiskTierService.test.ts \ + src/auth/register/registerService.test.ts \ + src/auth/startup/startupAuthService.test.ts +``` + +Lint and type-check: + +```bash +npx eslint src/db/repositories/userRepository.regression.test.ts +npx tsc --noEmit +``` + +> `npx tsc --noEmit` is compared against the repository baseline: this suite is +> test-only and introduces no new diagnostics. Use +> `npx tsc --noEmit 2>&1 | wc -l` before/after if the baseline is in question. + +## Expected evidence + +``` +npx jest src/db/repositories/userRepository.regression.test.ts --coverage=false +✓ 41/41 passed (1 suite) + +npm run test:coverage:user-failure +✓ 70/70 passed (2 suites) +✓ userRepository.ts: 100% statements / 100% branches / 100% functions / 100% lines + (gate: 95% — every failure exit is provably executed) + +npx jest --coverage=false \ + src/db/repositories/userRepository.test.ts \ + src/db/repositories/userRepository.regression.test.ts \ + src/services/__tests__/kycRiskTierService.test.ts \ + src/auth/register/registerService.test.ts \ + src/services/startupAuthService.test.ts +✓ 101/101 passed (5 suites) + +npx eslint src/db/repositories/userRepository.regression.test.ts +✓ exit 0 + +npx tsc --noEmit +➖ 30 diagnostics, all pre-existing; zero reference the new regression file +``` + +## CI note + +The upstream `CI Gate` workflow (`.github/workflows/ci.yml`) is wired to the +`main` branch while the repository default branch is `master`, and it currently +runs only `audit:ci` and `validate:alert-mappings` — neither executes Jest, so +this suite cannot gate merges through that workflow as-is. `check-postmortem` +does run on pull requests and passes. Adding a Jest job (and pointing the +workflow at the default branch) is a repository-wide CI change and is +deliberately **out of scope** for this test-only PR; it should be raised +separately with the maintainers. diff --git a/package.json b/package.json index 811739ef..01ff565c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:coverage": "jest --coverage --runInBand", "test:coverage:backend-011": "jest --runInBand --coverage src/middleware/rateLimit.test.ts src/middleware/startupAuthRateTierPolicy.test.ts src/routes/health.test.ts --collectCoverageFrom='src/middleware/rateLimit.ts' --collectCoverageFrom='src/middleware/startupAuthRateTierPolicy.ts' --coverageThreshold='{\"global\":{\"statements\":95,\"lines\":95,\"functions\":95}}'", "test:coverage:social-anti-enum": "jest --runInBand --coverage src/middleware/socialAntiEnumerationMiddleware.test.ts src/middleware/socialAntiEnumerationMiddleware.regression.test.ts --collectCoverageFrom=\"src/middleware/socialAntiEnumerationMiddleware.ts\" --coverageThreshold=\"{\\\"global\\\":{\\\"statements\\\":95,\\\"lines\\\":95,\\\"functions\\\":95,\\\"branches\\\":95}}\"", + "test:coverage:user-failure": "jest --runInBand --coverage src/db/repositories/userRepository.test.ts src/db/repositories/userRepository.regression.test.ts --collectCoverageFrom=\"src/db/repositories/userRepository.ts\" --coverageThreshold=\"{\\\"global\\\":{\\\"statements\\\":95,\\\"lines\\\":95,\\\"functions\\\":95,\\\"branches\\\":95}}\"", "audit:ci": "ts-node scripts/audit-gate.ts", "validate:alert-mappings": "ts-node scripts/validate-alert-mappings.ts", "drill:ttl-check": "ts-node scripts/failover-drill/ttl-check.ts", diff --git a/src/db/repositories/userRepository.regression.test.ts b/src/db/repositories/userRepository.regression.test.ts new file mode 100644 index 00000000..255a84ae --- /dev/null +++ b/src/db/repositories/userRepository.regression.test.ts @@ -0,0 +1,663 @@ +/** + * @file src/db/repositories/userRepository.regression.test.ts + * @description + * Focused regression suite for the three explicit failure / empty-result exits + * named in issue #1027 of `src/db/repositories/userRepository.ts`: + * + * - line 129 — `createUser`: `if (result.rows.length === 0) throw new Error('Failed to create user')` + * - line 182 — `updateUser` (no updatable fields): `if (!existing) throw new Error('User not found')` + * - line 201 — `updateUser`: `if (result.rows.length === 0) throw new Error('Failed to update user')` + * + * Why a separate file? + * -------------------- + * `userRepository.test.ts` documents the happy path and the obvious failure + * cases. This file *pins the contract* so a future refactor cannot silently + * soften an empty-result guard into a success, a partial object, or a different + * error class. Every assertion is intentionally about observable behaviour: + * the rejection class, the exact message, the number of SQL statements issued + * and the parameters they received. + * + * Test strategy: + * - `pg.Pool` is mocked; no database, network or environment is required. + * - Each `describe` block maps to exactly one of the three failure exits. + * - Each block also covers its *neighbouring* success path and boundary + * inputs (empty strings, `undefined` vs `null`, unknown stored tiers, + * hostile SQL payloads). + * - The guards are keyed on `rows.length`, never on `rowCount`; tests assert + * that a contradictory driver `rowCount` cannot change the outcome. + * + * Security invariants verified: + * - Failure messages are fixed literals: they never contain SQL, the email or + * the password hash, so logs/alerts are not an information-disclosure or + * statement-probing surface. + * - Values supplied by callers are always bound as query parameters; hostile + * `email` payloads never appear in the generated SQL text. + * - Duplicate-email races (`23505`) stay distinguishable from empty-result + * failures (`UniqueConstraintError` vs plain `Error`). + * - Non-`23505` driver errors are re-thrown by identity (no wrapping, no + * message rewriting). + * - The public return contract is unchanged: this file contains tests only, + * so `userRepository.ts` still returns `User`/`User | null` exactly as before. + */ + +import { Pool, QueryResult, QueryResultRow } from 'pg'; +import { UserRepository, User } from './userRepository'; +import { UniqueConstraintError } from '../../lib/errors'; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const USER_ID = 'user-123'; + +/** A complete `users` row as the driver would return it. */ +const STORED_ROW: User = { + id: USER_ID, + email: 'alice@example.com', + password_hash: 'salt:hash', + name: 'Alice', + role: 'investor', + kyc_risk_tier: 'standard', + last_oidc_groups: ['investors'], + created_at: new Date('2024-01-01T00:00:00.000Z'), + updated_at: new Date('2024-01-02T00:00:00.000Z'), +}; + +const CREATE_INPUT = { + email: 'bob@example.com', + password_hash: 'bob:salt:hash', + name: 'Bob', + role: 'startup' as const, + kyc_risk_tier: 'elevated' as const, +}; + +/** + * Builds a `pg.QueryResult` with an explicit `rowCount`. + * + * `rowCount` is deliberately decoupled from `rows.length` so tests can model a + * driver/proxy that reports a stale or optimistic count while returning no rows. + */ +function makeResult( + rows: T[], + rowCount: number = rows.length, + command = 'SELECT', +): QueryResult { + return { rows, rowCount, command, oid: 0, fields: [] }; +} + +/** Captures a rejection without letting an unexpected resolution pass silently. */ +async function captureError(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as Error; + } + throw new Error('Expected the repository call to reject, but it resolved'); +} + +// ─── Suite ─────────────────────────────────────────────────────────────────── + +describe('UserRepository failure-handling regressions (#1027)', () => { + let repository: UserRepository; + let mockPool: { query: jest.Mock }; + + beforeEach(() => { + mockPool = { query: jest.fn() }; + repository = new UserRepository(mockPool as unknown as Pool); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ══ line 129: createUser — empty RETURNING set ═══════════════════════════ + + describe('createUser — `Failed to create user` guard (line 129)', () => { + it('rejects with a plain Error carrying the exact pinned message', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([])); + + const error = await captureError(repository.createUser(CREATE_INPUT)); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('Error'); + expect(error.message).toBe('Failed to create user'); + }); + + it('does not surface a UniqueConstraintError for an empty result', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([])); + + await expect(repository.createUser(CREATE_INPUT)).rejects.not.toBeInstanceOf( + UniqueConstraintError, + ); + }); + + it('keys the guard on rows.length even when the driver reports rowCount: 1', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([], 1, 'INSERT')); + + await expect(repository.createUser(CREATE_INPUT)).rejects.toThrow( + 'Failed to create user', + ); + }); + + it('never resolves with a fabricated or partial user', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([])); + + let resolved: User | undefined; + try { + resolved = await repository.createUser(CREATE_INPUT); + } catch { + // Expected: the empty RETURNING set must fail loudly. + } + + expect(resolved).toBeUndefined(); + }); + + it('fails with a TypeError rather than fabricating a user when the driver returns rows: [null]', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([null] as unknown as User[])); + + await expect(repository.createUser(CREATE_INPUT)).rejects.toBeInstanceOf(TypeError); + }); + + it('is deterministic across repeated calls', async () => { + const outcomes: string[] = []; + for (let attempt = 0; attempt < 3; attempt += 1) { + mockPool.query.mockResolvedValueOnce(makeResult([])); + outcomes.push( + await repository + .createUser(CREATE_INPUT) + .then(() => 'resolved', (error: Error) => error.message), + ); + } + + expect(outcomes).toEqual([ + 'Failed to create user', + 'Failed to create user', + 'Failed to create user', + ]); + }); + + it('leaks no SQL text, email address or password hash in the error message', async () => { + const email = 'secret@example.com'; + const passwordHash = 'super:secret:hash'; + mockPool.query.mockResolvedValueOnce(makeResult([])); + + const error = await captureError( + repository.createUser({ email, password_hash: passwordHash }), + ); + + expect(error.message).toBe('Failed to create user'); + expect(error.message).not.toContain('INSERT'); + expect(error.message).not.toContain('users'); + expect(error.message).not.toContain(email); + expect(error.message).not.toContain(passwordHash); + }); + }); + + // ── Neighbouring success path and boundaries (createUser) ──────────────── + + describe('createUser — success path and boundaries', () => { + it('returns the fully mapped user when the INSERT returns a row', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([STORED_ROW], 1, 'INSERT')); + + const result = await repository.createUser(CREATE_INPUT); + + expect(result).toEqual(STORED_ROW); + expect(result.password_hash).toBe('salt:hash'); + }); + + it('binds exactly five parameters in positional order', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([STORED_ROW], 1, 'INSERT')); + + await repository.createUser(CREATE_INPUT); + + const [query, params] = mockPool.query.mock.calls[0] as [string, unknown[]]; + expect(query).toContain('INSERT INTO users'); + expect(params).toEqual([ + 'bob@example.com', + 'bob:salt:hash', + 'Bob', + 'startup', + 'elevated', + ]); + }); + + it('defaults role to startup, tier to standard and name to NULL', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([STORED_ROW], 1, 'INSERT')); + + await repository.createUser({ email: 'x@y.com', password_hash: 'h' }); + + const params = mockPool.query.mock.calls[0][1] as unknown[]; + expect(params).toEqual(['x@y.com', 'h', null, 'startup', 'standard']); + }); + + it('forwards boundary empty-string credentials as bound parameters', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([STORED_ROW], 1, 'INSERT')); + + await repository.createUser({ email: '', password_hash: '' }); + + const params = mockPool.query.mock.calls[0][1] as unknown[]; + // No validation happens at the repository layer; callers own normalisation. + expect(params[0]).toBe(''); + expect(params[1]).toBe(''); + }); + + it('binds a hostile email payload instead of interpolating it into SQL', async () => { + const payload = "x@y.com'); DROP TABLE users; --"; + mockPool.query.mockResolvedValueOnce(makeResult([STORED_ROW], 1, 'INSERT')); + + await repository.createUser({ email: payload, password_hash: 'h' }); + + const [query, params] = mockPool.query.mock.calls[0] as [string, unknown[]]; + expect(params[0]).toBe(payload); + expect(query).not.toContain(payload); + expect(query).not.toContain('DROP TABLE'); + }); + + it('coerces an unknown stored KYC tier to standard', async () => { + const row = { ...STORED_ROW, kyc_risk_tier: 'ultra' }; + mockPool.query.mockResolvedValueOnce(makeResult([row], 1, 'INSERT')); + + const result = await repository.createUser(CREATE_INPUT); + + expect(result.kyc_risk_tier).toBe('standard'); + }); + + it('maps a stored NULL name to undefined and absent groups to null', async () => { + const row = { ...STORED_ROW, name: null, last_oidc_groups: undefined }; + mockPool.query.mockResolvedValueOnce( + makeResult([row as unknown as User], 1, 'INSERT'), + ); + + const result = await repository.createUser(CREATE_INPUT); + + expect(result.name).toBeUndefined(); + expect(result.last_oidc_groups).toBeNull(); + }); + }); + + // ══ line 182: updateUser — no fields + missing user ══════════════════════ + + describe('updateUser — `User not found` guard (line 182)', () => { + it('rejects with a plain Error carrying the exact pinned message', async () => { + // No updatable field is supplied, so the method takes the read-only path + // and findById returns null. + mockPool.query.mockResolvedValueOnce(makeResult([])); + + const error = await captureError(repository.updateUser({ id: 'ghost' })); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('Error'); + expect(error.message).toBe('User not found'); + }); + + it('does not surface a UniqueConstraintError for an absent user', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([])); + + await expect(repository.updateUser({ id: 'ghost' })).rejects.not.toBeInstanceOf( + UniqueConstraintError, + ); + }); + + it('issues exactly one SELECT and never an UPDATE', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([])); + + await expect(repository.updateUser({ id: USER_ID })).rejects.toThrow( + 'User not found', + ); + + expect(mockPool.query).toHaveBeenCalledTimes(1); + const [query, params] = mockPool.query.mock.calls[0] as [string, unknown[]]; + expect(query).toContain('SELECT'); + expect(query).not.toContain('UPDATE'); + expect(params).toEqual([USER_ID]); + }); + + it('treats explicitly-undefined optional fields as "not provided"', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([])); + + await expect( + repository.updateUser({ + id: USER_ID, + email: undefined, + name: undefined, + password_hash: undefined, + role: undefined, + kyc_risk_tier: undefined, + last_oidc_groups: undefined, + }), + ).rejects.toThrow('User not found'); + + // Only the no-op read ran; an explicitly undefined field must not trigger + // an UPDATE with a useless or NULL assignment. + expect(mockPool.query).toHaveBeenCalledTimes(1); + expect(mockPool.query.mock.calls[0][0]).toContain('SELECT'); + }); + + it('treats an empty-string id as an ordinary lookup miss', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([])); + + await expect(repository.updateUser({ id: '' })).rejects.toThrow('User not found'); + + expect(mockPool.query.mock.calls[0][1]).toEqual(['']); + }); + + it('is deterministic across repeated calls', async () => { + const outcomes: string[] = []; + for (let attempt = 0; attempt < 3; attempt += 1) { + mockPool.query.mockResolvedValueOnce(makeResult([])); + outcomes.push( + await repository + .updateUser({ id: 'ghost' }) + .then(() => 'resolved', (error: Error) => error.message), + ); + } + + expect(outcomes).toEqual(['User not found', 'User not found', 'User not found']); + }); + + it('never resolves with a user when the lookup misses', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([])); + + let resolved: User | undefined; + try { + resolved = await repository.updateUser({ id: 'ghost' }); + } catch { + // Expected. + } + + expect(resolved).toBeUndefined(); + }); + + it('returns the existing user without a DB write when no fields are provided', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([STORED_ROW])); + + const result = await repository.updateUser({ id: USER_ID }); + + expect(result).toEqual(STORED_ROW); + expect(mockPool.query).toHaveBeenCalledTimes(1); + expect(mockPool.query.mock.calls[0][0]).toContain('SELECT'); + }); + + it('returns the mapped existing user on the no-op path', async () => { + const row = { ...STORED_ROW, name: null, kyc_risk_tier: 'unsupported' }; + mockPool.query.mockResolvedValueOnce(makeResult([row as unknown as User])); + + const result = await repository.updateUser({ id: USER_ID }); + + expect(result.name).toBeUndefined(); + expect(result.kyc_risk_tier).toBe('standard'); + // Missing last_oidc_groups is normalised to null, never undefined. + const rowWithoutGroups = { ...row } as Partial; + delete rowWithoutGroups.last_oidc_groups; + mockPool.query.mockResolvedValueOnce( + makeResult([rowWithoutGroups as unknown as User]), + ); + const second = await repository.updateUser({ id: USER_ID }); + expect(second.last_oidc_groups).toBeNull(); + }); + }); + + // ══ line 201: updateUser — empty RETURNING set ═══════════════════════════ + + describe('updateUser — `Failed to update user` guard (line 201)', () => { + it('rejects with a plain Error carrying the exact pinned message', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([], 0, 'UPDATE')); + + const error = await captureError( + repository.updateUser({ id: USER_ID, email: 'new@example.com' }), + ); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('Error'); + expect(error.message).toBe('Failed to update user'); + }); + + it('does not surface a UniqueConstraintError for an empty result', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([], 0, 'UPDATE')); + + await expect( + repository.updateUser({ id: USER_ID, email: 'new@example.com' }), + ).rejects.not.toBeInstanceOf(UniqueConstraintError); + }); + + it('keys the guard on rows.length even when the driver reports rowCount: 1', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([], 1, 'UPDATE')); + + await expect( + repository.updateUser({ id: USER_ID, email: 'new@example.com' }), + ).rejects.toThrow('Failed to update user'); + }); + + it('never resolves with a fabricated or partial user', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([], 0, 'UPDATE')); + + let resolved: User | undefined; + try { + resolved = await repository.updateUser({ id: USER_ID, name: 'Renamed' }); + } catch { + // Expected. + } + + expect(resolved).toBeUndefined(); + }); + + it('issues exactly one UPDATE statement with the id bound last', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([], 0, 'UPDATE')); + + await expect( + repository.updateUser({ id: USER_ID, email: 'new@example.com' }), + ).rejects.toThrow('Failed to update user'); + + expect(mockPool.query).toHaveBeenCalledTimes(1); + const [query, params] = mockPool.query.mock.calls[0] as [string, unknown[]]; + expect(query).toContain('UPDATE users'); + expect(query).toContain('RETURNING *'); + expect(params).toEqual(['new@example.com', USER_ID]); + }); + + it('keeps a 23505 driver error mapped to UniqueConstraintError(field: email)', async () => { + const uniqueViolation = Object.assign(new Error('duplicate key value'), { + code: '23505', + }); + mockPool.query.mockRejectedValueOnce(uniqueViolation); + + const error = await captureError( + repository.updateUser({ id: USER_ID, email: 'taken@example.com' }), + ); + + expect(error).toBeInstanceOf(UniqueConstraintError); + expect(error).toMatchObject({ field: 'email' }); + expect(error.message).toBe('Unique constraint violation on email'); + }); + + it('re-throws non-23505 driver errors by identity', async () => { + const driverError = Object.assign(new Error('connection reset'), { + code: 'ECONNRESET', + }); + mockPool.query.mockRejectedValueOnce(driverError); + + await expect( + repository.updateUser({ id: USER_ID, email: 'new@example.com' }), + ).rejects.toBe(driverError); + }); + + it('leaks no SQL text, email address or password hash in the error message', async () => { + const email = 'secret@example.com'; + const passwordHash = 'super:secret:hash'; + mockPool.query.mockResolvedValueOnce(makeResult([], 0, 'UPDATE')); + + const error = await captureError( + repository.updateUser({ id: USER_ID, email, password_hash: passwordHash }), + ); + + expect(error.message).toBe('Failed to update user'); + expect(error.message).not.toContain('UPDATE'); + expect(error.message).not.toContain(email); + expect(error.message).not.toContain(passwordHash); + }); + + it('is deterministic across repeated calls', async () => { + const outcomes: string[] = []; + for (let attempt = 0; attempt < 3; attempt += 1) { + mockPool.query.mockResolvedValueOnce(makeResult([], 0, 'UPDATE')); + outcomes.push( + await repository + .updateUser({ id: USER_ID, email: 'new@example.com' }) + .then(() => 'resolved', (error: Error) => error.message), + ); + } + + expect(outcomes).toEqual([ + 'Failed to update user', + 'Failed to update user', + 'Failed to update user', + ]); + }); + }); + + // ── Neighbouring success path and boundaries (updateUser) ──────────────── + + describe('updateUser — success path and boundaries', () => { + it('binds every supported field in declaration order with the id last', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([STORED_ROW], 1, 'UPDATE')); + + await repository.updateUser({ + id: USER_ID, + email: 'new@example.com', + name: 'Alice Updated', + password_hash: 'new:salt:hash', + role: 'startup', + kyc_risk_tier: 'high', + last_oidc_groups: ['ops', 'investors'], + }); + + const [query, params] = mockPool.query.mock.calls[0] as [string, unknown[]]; + expect(params).toEqual([ + 'new@example.com', + 'Alice Updated', + 'new:salt:hash', + 'startup', + 'high', + '["ops","investors"]', + USER_ID, + ]); + expect(query).toContain('email = $1'); + expect(query).toContain('name = $2'); + expect(query).toContain('password_hash = $3'); + expect(query).toContain('role = $4'); + expect(query).toContain('kyc_risk_tier = $5'); + expect(query).toContain('last_oidc_groups = $6'); + expect(query).toContain('updated_at = NOW()'); + expect(query).toContain('WHERE id = $7'); + }); + + it('treats last_oidc_groups: null as a provided value that clears the column', async () => { + mockPool.query.mockResolvedValueOnce( + makeResult([{ ...STORED_ROW, last_oidc_groups: null }], 1, 'UPDATE'), + ); + + const result = await repository.updateUser({ + id: USER_ID, + last_oidc_groups: null, + }); + + const [query, params] = mockPool.query.mock.calls[0] as [string, unknown[]]; + expect(query).toContain('last_oidc_groups = $1'); + expect(query).toContain('UPDATE users'); + expect(params).toEqual([null, USER_ID]); + expect(result.last_oidc_groups).toBeNull(); + }); + + it('serialises an empty group list to "[]" instead of treating it as absent', async () => { + mockPool.query.mockResolvedValueOnce( + makeResult([{ ...STORED_ROW, last_oidc_groups: [] }], 1, 'UPDATE'), + ); + + await repository.updateUser({ id: USER_ID, last_oidc_groups: [] }); + + const params = mockPool.query.mock.calls[0][1] as unknown[]; + expect(params).toEqual(['[]', USER_ID]); + }); + + it('treats an empty-string email as provided (validation belongs to callers)', async () => { + mockPool.query.mockResolvedValueOnce( + makeResult([{ ...STORED_ROW, email: '' }], 1, 'UPDATE'), + ); + + await repository.updateUser({ id: USER_ID, email: '' }); + + const params = mockPool.query.mock.calls[0][1] as unknown[]; + expect(params).toEqual(['', USER_ID]); + }); + + it('binds a hostile email payload instead of interpolating it into SQL', async () => { + const payload = "new@example.com'); DROP TABLE users; --"; + mockPool.query.mockResolvedValueOnce(makeResult([STORED_ROW], 1, 'UPDATE')); + + await repository.updateUser({ id: USER_ID, email: payload }); + + const [query, params] = mockPool.query.mock.calls[0] as [string, unknown[]]; + expect(params[0]).toBe(payload); + expect(query).not.toContain(payload); + expect(query).not.toContain('DROP TABLE'); + }); + + it('returns the fully mapped updated user', async () => { + const row = { + ...STORED_ROW, + name: null, + kyc_risk_tier: 'unsupported', + last_oidc_groups: null, + }; + mockPool.query.mockResolvedValueOnce( + makeResult([row as unknown as User], 1, 'UPDATE'), + ); + + const result = await repository.updateUser({ id: USER_ID, name: 'ignored' }); + + expect(result.name).toBeUndefined(); + expect(result.kyc_risk_tier).toBe('standard'); + expect(result.last_oidc_groups).toBeNull(); + }); + }); + + // ── Caller-facing delegation (updateKycRiskTier) ───────────────────────── + + describe('updateKycRiskTier — delegation to the pinned update contract', () => { + it('delegates to updateUser and binds the requested tier', async () => { + mockPool.query.mockResolvedValueOnce( + makeResult([{ ...STORED_ROW, kyc_risk_tier: 'high' }], 1, 'UPDATE'), + ); + + const result = await repository.updateKycRiskTier(USER_ID, 'high'); + + const [query, params] = mockPool.query.mock.calls[0] as [string, unknown[]]; + expect(query).toContain('kyc_risk_tier = $1'); + expect(query).toContain('WHERE id = $2'); + expect(params).toEqual(['high', USER_ID]); + expect(result.kyc_risk_tier).toBe('high'); + }); + + it('propagates `Failed to update user` when the UPDATE matches no row', async () => { + mockPool.query.mockResolvedValueOnce(makeResult([], 0, 'UPDATE')); + + await expect(repository.updateKycRiskTier('ghost', 'high')).rejects.toThrow( + 'Failed to update user', + ); + }); + + it('propagates UniqueConstraintError for a 23505 driver failure', async () => { + mockPool.query.mockRejectedValueOnce( + Object.assign(new Error('duplicate key value'), { code: '23505' }), + ); + + await expect( + repository.updateKycRiskTier(USER_ID, 'elevated'), + ).rejects.toBeInstanceOf(UniqueConstraintError); + }); + }); +}); + + + + + +