diff --git a/src/svrs/auth/adapters/db.ts b/src/svrs/auth/adapters/db.ts index 2cb73a4..5bb70a7 100644 --- a/src/svrs/auth/adapters/db.ts +++ b/src/svrs/auth/adapters/db.ts @@ -72,9 +72,16 @@ export function createDbAuthAdapter(repos: AuthDbRepositories): AuthStoreAdapter { consumedAt: input.nowMs } ), revokeFlows: async (input) => { - const flows = await repos.flows.findMany( - whereOf(input) - ); + // Build the where clause explicitly: forwarding `whereOf(input)` + // would leak `nowMs` into the predicate, which a real SQL + // translator turns into `WHERE now_ms = ?` and produces a + // silent no-op (the column doesn't exist or never matches). + const where: { -readonly [K in keyof typeof input]?: typeof input[K] } = { + tenantId: input.tenantId + }; + if (input.actorRef !== undefined) where.actorRef = input.actorRef; + if (input.kind !== undefined) where.kind = input.kind; + const flows = await repos.flows.findMany(where as Readonly>); await Promise.all( flows.map((flow) => repos.flows.update({ id: flow.id }, { consumedAt: input.nowMs })) ); diff --git a/src/svrs/auth/test/db-adapter-contract.test.ts b/src/svrs/auth/test/db-adapter-contract.test.ts new file mode 100644 index 0000000..626b468 --- /dev/null +++ b/src/svrs/auth/test/db-adapter-contract.test.ts @@ -0,0 +1,581 @@ +/** + * Contract tests for `createDbAuthAdapter`. The DB adapter is the + * auth artifact's pluggable persistence facade — it forwards + * `AuthStoreAdapter` calls to a `AuthDbRepositories` injection that + * the application wires up against its real ORM/SQL backend. + * + * Until now the adapter only had a runtime safety test + * (`memory-adapter.test.ts` covers the in-memory STORE adapter, not + * the DB adapter facade). G4 in the codex audit deferred contract + * tests pending a substantial in-memory SQL fake — this file is that + * fake (`db-adapter-fake.ts`) plus the contract suite. + * + * Each test asserts the adapter produces the expected row state in + * the underlying repos. Where the adapter does cascade work + * (`revokeDevice`, `revokeActorSessions`, `revokeRefreshFamily`, + * `revokeFlows`), the cascade is verified by reading the store + * directly after the call returns. + */ + +import { describe, expect, it } from 'vitest'; +import { + AUTH_AAL, + AUTH_AMR, + AUTH_CREDENTIAL_KINDS, + AUTH_FLOW_KINDS, + AUTH_REVOKE_REASONS +} from '$libs/auth/consts'; +import { createDbAuthAdapter } from '../adapters/db.ts'; +import { + createMemoryAuthDbRepositories, + type MemoryAuthDbStore +} from './db-adapter-fake.ts'; +import type { + AuthActorRef, + AuthCredentialId, + AuthDeviceId, + AuthFlowId, + AuthLinkedAccountId, + AuthPasswordHash, + AuthRefreshFamilyId, + AuthRefreshTokenId, + AuthSessionId, + AuthTenantId +} from '$libs/auth/types'; + +const TENANT_A = 'tenant-A' as AuthTenantId; +const TENANT_B = 'tenant-B' as AuthTenantId; +const ACTOR_A1: AuthActorRef = { tenantId: TENANT_A, actorId: 'actor-A1' as never }; +const ACTOR_A2: AuthActorRef = { tenantId: TENANT_A, actorId: 'actor-A2' as never }; +const ACTOR_B1: AuthActorRef = { tenantId: TENANT_B, actorId: 'actor-B1' as never }; + +const NOW = 1_700_000_000_000; +const LATER = NOW + 60_000; + +const HASH_PWD: AuthPasswordHash = 'argon2id$v=19$m=65536,t=2,p=1$abc' as AuthPasswordHash; +const HASH_PWD_2: AuthPasswordHash = + 'argon2id$v=19$m=65536,t=2,p=1$xyz' as AuthPasswordHash; + +function setup(): { + store: MemoryAuthDbStore; + adapter: ReturnType; +} { + const { store, repos } = createMemoryAuthDbRepositories(); + return { store, adapter: createDbAuthAdapter(repos) }; +} + +// ── Credentials ──────────────────────────────────────────────────────── + +describe('createDbAuthAdapter — credentials', () => { + it('creates a password credential and finds it by tenant + identifier', async () => { + const { adapter, store } = setup(); + const created = await adapter.createPasswordCredential({ + id: 'cred-1' as AuthCredentialId, + actorRef: ACTOR_A1, + identifierHash: 'h(alice@a.com)', + identifierDisplay: 'alice@a.com', + passwordHash: HASH_PWD, + nowMs: NOW + }); + expect(created.kind).toBe(AUTH_CREDENTIAL_KINDS.PASSWORD); + expect(store.credentials.size).toBe(1); + + const found = await adapter.findCredentialByIdentifier({ + tenantId: TENANT_A, + kind: AUTH_CREDENTIAL_KINDS.PASSWORD, + identifierHash: 'h(alice@a.com)' + }); + expect(found?.id).toBe('cred-1'); + }); + + it('isolates credential lookups by tenant', async () => { + const { adapter } = setup(); + await adapter.createPasswordCredential({ + id: 'cred-A' as AuthCredentialId, + actorRef: ACTOR_A1, + identifierHash: 'shared', + identifierDisplay: 'a@x', + passwordHash: HASH_PWD, + nowMs: NOW + }); + await adapter.createPasswordCredential({ + id: 'cred-B' as AuthCredentialId, + actorRef: ACTOR_B1, + identifierHash: 'shared', + identifierDisplay: 'b@x', + passwordHash: HASH_PWD, + nowMs: NOW + }); + + const fromA = await adapter.findCredentialByIdentifier({ + tenantId: TENANT_A, + kind: AUTH_CREDENTIAL_KINDS.PASSWORD, + identifierHash: 'shared' + }); + expect(fromA?.id).toBe('cred-A'); + }); + + it('updatePasswordCredential rotates the hash and bumps updatedAt', async () => { + const { adapter, store } = setup(); + await adapter.createPasswordCredential({ + id: 'cred-1' as AuthCredentialId, + actorRef: ACTOR_A1, + identifierHash: 'h', + identifierDisplay: 'a@x', + passwordHash: HASH_PWD, + nowMs: NOW + }); + await adapter.updatePasswordCredential({ + tenantId: TENANT_A, + credentialId: 'cred-1' as AuthCredentialId, + passwordHash: HASH_PWD_2, + nowMs: LATER + }); + const row = store.credentials.get('cred-1')!; + expect(row.passwordHash).toBe(HASH_PWD_2); + expect(row.updatedAt).toBe(LATER); + expect(row.createdAt).toBe(NOW); // unchanged + }); + + it('updatePasswordCredential with a foreign tenant is a no-op', async () => { + const { adapter, store } = setup(); + await adapter.createPasswordCredential({ + id: 'cred-1' as AuthCredentialId, + actorRef: ACTOR_A1, + identifierHash: 'h', + identifierDisplay: 'a@x', + passwordHash: HASH_PWD, + nowMs: NOW + }); + await adapter.updatePasswordCredential({ + tenantId: TENANT_B, // foreign + credentialId: 'cred-1' as AuthCredentialId, + passwordHash: HASH_PWD_2, + nowMs: LATER + }); + const row = store.credentials.get('cred-1')!; + expect(row.passwordHash).toBe(HASH_PWD); // unchanged + }); + + it('markCredentialVerified sets verifiedAt + updatedAt', async () => { + const { adapter, store } = setup(); + await adapter.createPasswordCredential({ + id: 'cred-1' as AuthCredentialId, + actorRef: ACTOR_A1, + identifierHash: 'h', + identifierDisplay: 'a@x', + passwordHash: HASH_PWD, + nowMs: NOW + }); + await adapter.markCredentialVerified({ + tenantId: TENANT_A, + credentialId: 'cred-1' as AuthCredentialId, + nowMs: LATER + }); + const row = store.credentials.get('cred-1')!; + expect(row.verifiedAt).toBe(LATER); + expect(row.updatedAt).toBe(LATER); + }); +}); + +// ── Flows ────────────────────────────────────────────────────────────── + +describe('createDbAuthAdapter — flows', () => { + function makeFlow(id: string, kind: typeof AUTH_FLOW_KINDS[keyof typeof AUTH_FLOW_KINDS], extra: Record = {}) { + return { + id: id as AuthFlowId, + tenantId: TENANT_A, + kind, + expiresAt: NOW + 600_000, + createdAt: NOW, + ...extra + }; + } + + it('createFlow + findFlowForUpdate round-trip with kind narrowing', async () => { + const { adapter } = setup(); + await adapter.createFlow(makeFlow('flow-1', AUTH_FLOW_KINDS.EMAIL_VERIFICATION)); + await adapter.createFlow(makeFlow('flow-2', AUTH_FLOW_KINDS.PASSWORD_RESET)); + + const matched = await adapter.findFlowForUpdate({ + tenantId: TENANT_A, + flowId: 'flow-2' as AuthFlowId, + kind: AUTH_FLOW_KINDS.PASSWORD_RESET + }); + expect(matched?.id).toBe('flow-2'); + + // Mismatched kind narrows away the row. + const wrongKind = await adapter.findFlowForUpdate({ + tenantId: TENANT_A, + flowId: 'flow-2' as AuthFlowId, + kind: AUTH_FLOW_KINDS.EMAIL_VERIFICATION + }); + expect(wrongKind).toBeNull(); + }); + + it('consumeFlow stamps consumedAt', async () => { + const { adapter, store } = setup(); + await adapter.createFlow(makeFlow('flow-1', AUTH_FLOW_KINDS.EMAIL_VERIFICATION)); + await adapter.consumeFlow({ + tenantId: TENANT_A, + flowId: 'flow-1' as AuthFlowId, + nowMs: LATER + }); + expect(store.flows.get('flow-1')?.consumedAt).toBe(LATER); + }); + + it('revokeFlows scoped by tenant + actor + kind cascades to all matching rows', async () => { + const { adapter, store } = setup(); + await adapter.createFlow( + makeFlow('flow-A1-pw1', AUTH_FLOW_KINDS.PASSWORD_RESET, { actorRef: ACTOR_A1 }) + ); + await adapter.createFlow( + makeFlow('flow-A1-pw2', AUTH_FLOW_KINDS.PASSWORD_RESET, { actorRef: ACTOR_A1 }) + ); + await adapter.createFlow( + makeFlow('flow-A1-ev', AUTH_FLOW_KINDS.EMAIL_VERIFICATION, { actorRef: ACTOR_A1 }) + ); + await adapter.createFlow( + makeFlow('flow-A2-pw', AUTH_FLOW_KINDS.PASSWORD_RESET, { actorRef: ACTOR_A2 }) + ); + + await adapter.revokeFlows({ + tenantId: TENANT_A, + actorRef: ACTOR_A1, + kind: AUTH_FLOW_KINDS.PASSWORD_RESET, + nowMs: LATER + }); + + // A1's two PW flows are consumed; A1's EV flow and A2's PW flow are untouched. + expect(store.flows.get('flow-A1-pw1')?.consumedAt).toBe(LATER); + expect(store.flows.get('flow-A1-pw2')?.consumedAt).toBe(LATER); + expect(store.flows.get('flow-A1-ev')?.consumedAt).toBeUndefined(); + expect(store.flows.get('flow-A2-pw')?.consumedAt).toBeUndefined(); + }); +}); + +// ── Linked accounts ──────────────────────────────────────────────────── + +describe('createDbAuthAdapter — linked accounts', () => { + it('linkAccount + findLinkedAccount round-trip scoped by tenant', async () => { + const { adapter } = setup(); + await adapter.linkAccount({ + id: 'la-1' as AuthLinkedAccountId, + actorRef: ACTOR_A1, + providerId: 'google', + providerKind: 'oidc', + providerSubject: 'sub-123', + email: 'alice@a.com', + emailVerified: true, + nowMs: NOW + }); + const found = await adapter.findLinkedAccount({ + tenantId: TENANT_A, + providerId: 'google', + providerSubject: 'sub-123' + }); + expect(found?.id).toBe('la-1'); + + // Same provider+subject under a different tenant is invisible. + const cross = await adapter.findLinkedAccount({ + tenantId: TENANT_B, + providerId: 'google', + providerSubject: 'sub-123' + }); + expect(cross).toBeNull(); + }); +}); + +// ── Devices ──────────────────────────────────────────────────────────── + +describe('createDbAuthAdapter — devices', () => { + it('upsertDevice inserts when missing, updates lastSeenAt when present', async () => { + const { adapter, store } = setup(); + const inserted = await adapter.upsertDevice({ + actorRef: ACTOR_A1, + deviceId: 'dev-1' as AuthDeviceId, + displayName: 'Phone', + nowMs: NOW + }); + expect(inserted.firstSeenAt).toBe(NOW); + expect(inserted.lastSeenAt).toBe(NOW); + + const updated = await adapter.upsertDevice({ + actorRef: ACTOR_A1, + deviceId: 'dev-1' as AuthDeviceId, + nowMs: LATER + }); + expect(updated.firstSeenAt).toBe(NOW); // unchanged + expect(updated.lastSeenAt).toBe(LATER); + expect(store.devices.size).toBe(1); + }); + + it('listDevices is scoped to actorRef', async () => { + const { adapter } = setup(); + await adapter.upsertDevice({ + actorRef: ACTOR_A1, + deviceId: 'dev-1' as AuthDeviceId, + nowMs: NOW + }); + await adapter.upsertDevice({ + actorRef: ACTOR_A2, + deviceId: 'dev-2' as AuthDeviceId, + nowMs: NOW + }); + const a1 = await adapter.listDevices({ actorRef: ACTOR_A1 }); + expect(a1.map((d) => d.id)).toEqual(['dev-1']); + }); + + it('revokeDevice cascades into active session bindings on the same device', async () => { + const { adapter, store } = setup(); + await adapter.upsertDevice({ + actorRef: ACTOR_A1, + deviceId: 'dev-1' as AuthDeviceId, + nowMs: NOW + }); + await adapter.bindSession({ + sessSessionId: 's-1' as AuthSessionId, + actorRef: ACTOR_A1, + deviceId: 'dev-1' as AuthDeviceId, + aal: AUTH_AAL.SINGLE_FACTOR, + amr: [AUTH_AMR.PASSWORD], + authTime: NOW, + createdAt: NOW + }); + await adapter.bindSession({ + sessSessionId: 's-2' as AuthSessionId, + actorRef: ACTOR_A1, + deviceId: 'dev-1' as AuthDeviceId, + aal: AUTH_AAL.SINGLE_FACTOR, + amr: [AUTH_AMR.PASSWORD], + authTime: NOW, + createdAt: NOW + }); + // Independent binding on a different device — must NOT cascade. + await adapter.upsertDevice({ + actorRef: ACTOR_A1, + deviceId: 'dev-2' as AuthDeviceId, + nowMs: NOW + }); + await adapter.bindSession({ + sessSessionId: 's-3' as AuthSessionId, + actorRef: ACTOR_A1, + deviceId: 'dev-2' as AuthDeviceId, + aal: AUTH_AAL.SINGLE_FACTOR, + amr: [AUTH_AMR.PASSWORD], + authTime: NOW, + createdAt: NOW + }); + + const revoked = await adapter.revokeDevice({ + actorRef: ACTOR_A1, + deviceId: 'dev-1' as AuthDeviceId, + nowMs: LATER + }); + expect(new Set(revoked)).toEqual(new Set(['s-1', 's-2'])); + expect(store.devices.get('dev-1')?.revokedAt).toBe(LATER); + expect(store.sessionBindings.get('s-1')?.revokedAt).toBe(LATER); + expect(store.sessionBindings.get('s-1')?.revokeReason).toBe( + AUTH_REVOKE_REASONS.DEVICE_REVOKED + ); + expect(store.sessionBindings.get('s-2')?.revokedAt).toBe(LATER); + expect(store.sessionBindings.get('s-3')?.revokedAt).toBeUndefined(); + }); +}); + +// ── Session bindings ─────────────────────────────────────────────────── + +describe('createDbAuthAdapter — session bindings', () => { + async function bind(adapter: ReturnType, id: string, actor: AuthActorRef = ACTOR_A1): Promise { + await adapter.bindSession({ + sessSessionId: id as AuthSessionId, + actorRef: actor, + aal: AUTH_AAL.SINGLE_FACTOR, + amr: [AUTH_AMR.PASSWORD], + authTime: NOW, + createdAt: NOW + }); + } + + it('bindSession + findSessionBinding round-trip', async () => { + const { adapter } = setup(); + await bind(adapter, 's-1'); + const found = await adapter.findSessionBinding({ sessSessionId: 's-1' as AuthSessionId }); + expect(found?.actorRef.actorId).toBe(ACTOR_A1.actorId); + }); + + it('revokeSessionBinding stamps the row with reason + nowMs', async () => { + const { adapter, store } = setup(); + await bind(adapter, 's-1'); + await adapter.revokeSessionBinding({ + sessSessionId: 's-1' as AuthSessionId, + nowMs: LATER, + reason: AUTH_REVOKE_REASONS.LOGOUT + }); + const row = store.sessionBindings.get('s-1')!; + expect(row.revokedAt).toBe(LATER); + expect(row.revokeReason).toBe(AUTH_REVOKE_REASONS.LOGOUT); + }); + + it('revokeActorSessions cascades to every active binding for that actor', async () => { + const { adapter, store } = setup(); + await bind(adapter, 's-A1-1', ACTOR_A1); + await bind(adapter, 's-A1-2', ACTOR_A1); + await bind(adapter, 's-A2-1', ACTOR_A2); + + // Already-revoked rows must not be re-revoked. + await adapter.revokeSessionBinding({ + sessSessionId: 's-A1-2' as AuthSessionId, + nowMs: NOW, + reason: AUTH_REVOKE_REASONS.LOGOUT + }); + + const closed = await adapter.revokeActorSessions({ + actorRef: ACTOR_A1, + nowMs: LATER, + reason: AUTH_REVOKE_REASONS.PASSWORD_CHANGED + }); + expect(closed).toEqual(['s-A1-1']); + expect(store.sessionBindings.get('s-A1-1')?.revokedAt).toBe(LATER); + expect(store.sessionBindings.get('s-A1-1')?.revokeReason).toBe( + AUTH_REVOKE_REASONS.PASSWORD_CHANGED + ); + // Existing revoke timestamp is preserved (different reason, earlier ts). + expect(store.sessionBindings.get('s-A1-2')?.revokedAt).toBe(NOW); + expect(store.sessionBindings.get('s-A1-2')?.revokeReason).toBe( + AUTH_REVOKE_REASONS.LOGOUT + ); + // Other actor untouched. + expect(store.sessionBindings.get('s-A2-1')?.revokedAt).toBeUndefined(); + }); +}); + +// ── Refresh tokens ───────────────────────────────────────────────────── + +describe('createDbAuthAdapter — refresh tokens', () => { + it('createRefreshFamily + createRefreshToken + findRefreshTokenForUpdate', async () => { + const { adapter } = setup(); + await adapter.createRefreshFamily({ + id: 'fam-1' as AuthRefreshFamilyId, + actorRef: ACTOR_A1, + sessionId: 's-1' as AuthSessionId, + createdAt: NOW, + idleExpiresAt: NOW + 600_000 + }); + await adapter.createRefreshToken({ + id: 'rt-1' as AuthRefreshTokenId, + familyId: 'fam-1' as AuthRefreshFamilyId, + tokenHash: 'h(rt-1)', + issuedAt: NOW + }); + const found = await adapter.findRefreshTokenForUpdate({ tokenHash: 'h(rt-1)' }); + expect(found?.id).toBe('rt-1'); + }); + + it('rotateRefreshToken runs in a transaction, links current → child, and returns both', async () => { + const { adapter, store } = setup(); + await adapter.createRefreshFamily({ + id: 'fam-1' as AuthRefreshFamilyId, + actorRef: ACTOR_A1, + sessionId: 's-1' as AuthSessionId, + createdAt: NOW, + idleExpiresAt: NOW + 600_000 + }); + await adapter.createRefreshToken({ + id: 'rt-1' as AuthRefreshTokenId, + familyId: 'fam-1' as AuthRefreshFamilyId, + tokenHash: 'h(rt-1)', + issuedAt: NOW + }); + + const before = store.transactionCount; + const { current, child } = await adapter.rotateRefreshToken({ + currentTokenId: 'rt-1' as AuthRefreshTokenId, + childToken: { + id: 'rt-2' as AuthRefreshTokenId, + familyId: 'fam-1' as AuthRefreshFamilyId, + tokenHash: 'h(rt-2)', + parentTokenId: 'rt-1' as AuthRefreshTokenId, + issuedAt: LATER + }, + consumedAt: LATER + }); + expect(store.transactionCount).toBe(before + 1); + expect(current.consumedAt).toBe(LATER); + expect(current.childTokenId).toBe('rt-2'); + expect(child.id).toBe('rt-2'); + // Persisted rows match the returned values. + expect(store.refreshTokens.get('rt-1')?.consumedAt).toBe(LATER); + expect(store.refreshTokens.get('rt-1')?.childTokenId).toBe('rt-2'); + expect(store.refreshTokens.get('rt-2')?.parentTokenId).toBe('rt-1'); + }); + + it('rotateRefreshToken throws when the current token is missing', async () => { + const { adapter } = setup(); + await expect( + adapter.rotateRefreshToken({ + currentTokenId: 'rt-missing' as AuthRefreshTokenId, + childToken: { + id: 'rt-2' as AuthRefreshTokenId, + familyId: 'fam-1' as AuthRefreshFamilyId, + tokenHash: 'h(rt-2)', + issuedAt: LATER + }, + consumedAt: LATER + }) + ).rejects.toThrow(); + }); + + it('revokeRefreshFamily cascades revokedAt into every token in the family', async () => { + const { adapter, store } = setup(); + await adapter.createRefreshFamily({ + id: 'fam-1' as AuthRefreshFamilyId, + actorRef: ACTOR_A1, + sessionId: 's-1' as AuthSessionId, + createdAt: NOW, + idleExpiresAt: NOW + 600_000 + }); + await adapter.createRefreshToken({ + id: 'rt-1' as AuthRefreshTokenId, + familyId: 'fam-1' as AuthRefreshFamilyId, + tokenHash: 'h(rt-1)', + issuedAt: NOW + }); + await adapter.createRefreshToken({ + id: 'rt-2' as AuthRefreshTokenId, + familyId: 'fam-1' as AuthRefreshFamilyId, + tokenHash: 'h(rt-2)', + issuedAt: NOW + }); + // Independent family — must not be touched. + await adapter.createRefreshFamily({ + id: 'fam-2' as AuthRefreshFamilyId, + actorRef: ACTOR_A2, + sessionId: 's-2' as AuthSessionId, + createdAt: NOW, + idleExpiresAt: NOW + 600_000 + }); + await adapter.createRefreshToken({ + id: 'rt-3' as AuthRefreshTokenId, + familyId: 'fam-2' as AuthRefreshFamilyId, + tokenHash: 'h(rt-3)', + issuedAt: NOW + }); + + await adapter.revokeRefreshFamily({ + familyId: 'fam-1' as AuthRefreshFamilyId, + nowMs: LATER, + reason: AUTH_REVOKE_REASONS.REFRESH_REUSE + }); + + expect(store.refreshFamilies.get('fam-1')?.revokedAt).toBe(LATER); + expect(store.refreshFamilies.get('fam-1')?.revokeReason).toBe( + AUTH_REVOKE_REASONS.REFRESH_REUSE + ); + expect(store.refreshTokens.get('rt-1')?.revokedAt).toBe(LATER); + expect(store.refreshTokens.get('rt-2')?.revokedAt).toBe(LATER); + // Other family + its tokens unaffected. + expect(store.refreshFamilies.get('fam-2')?.revokedAt).toBeUndefined(); + expect(store.refreshTokens.get('rt-3')?.revokedAt).toBeUndefined(); + }); +}); diff --git a/src/svrs/auth/test/db-adapter-fake.ts b/src/svrs/auth/test/db-adapter-fake.ts new file mode 100644 index 0000000..cfa8d3e --- /dev/null +++ b/src/svrs/auth/test/db-adapter-fake.ts @@ -0,0 +1,194 @@ +/** + * Reference implementation of `AuthDbRepositories` for the + * `createDbAuthAdapter` contract tests. NOT a production adapter — it + * runs entirely in JS Maps and skips any kind of locking / isolation. + * + * Why this is non-trivial: + * + * `db.ts` forwards `where` clauses verbatim. The README explicitly + * documents the conventions a real consumer must translate to SQL — + * see "Reglas de Produccion" + "Refresh Rotation y Locks". The fake + * encodes the SAME conventions: + * + * - `{ tenantId, kind, identifierHash }` over credentials matches + * `record.actorRef.tenantId`, `record.kind`, `record.identifierHash`. + * - `{ flowId }` over flows matches `record.id` (label is the + * adapter's user-facing name; the row column is `id`). + * - `{ revokedAt: null }` matches `record.revokedAt === undefined` + * (TypeScript optional fields normalize to undefined; SQL `NULL` + * is the equivalent sentinel). + * - `actorRef` deep-eq instead of `===`. + * + * The fake keeps state in a `MemoryAuthDbStore` so the tests can poke + * at intermediate row state without going through the adapter — useful + * for asserting cascade revocations. + */ + +import type { + AuthCredentialRecord, + AuthDeviceRecord, + AuthFlowRecord, + AuthLinkedAccountRecord, + AuthRefreshFamilyRecord, + AuthRefreshTokenRecord, + AuthSessionBindingRecord +} from '$libs/auth/types'; +import type { AuthDbRepositories, AuthRepository } from '../adapters/db.ts'; + +export interface MemoryAuthDbStore { + readonly credentials: Map; + readonly flows: Map; + readonly linkedAccounts: Map; + readonly devices: Map; + readonly sessionBindings: Map; + readonly refreshFamilies: Map; + readonly refreshTokens: Map; + transactionDepth: number; + transactionCount: number; +} + +export function createMemoryAuthDbStore(): MemoryAuthDbStore { + return { + credentials: new Map(), + flows: new Map(), + linkedAccounts: new Map(), + devices: new Map(), + sessionBindings: new Map(), + refreshFamilies: new Map(), + refreshTokens: new Map(), + transactionDepth: 0, + transactionCount: 0 + }; +} + +type WhereAliasMap = Readonly>; + +/** + * Compare a `where` clause entry against a record value, honouring the + * same conventions a real SQL repo would: `null` in the where matches + * `undefined` in the record (both are absent), and objects compare via + * shallow deep-equality (sufficient for `actorRef = { tenantId, actorId }` + * and similar tuple-shaped fields). + */ +function whereValueMatches(whereValue: unknown, recordValue: unknown): boolean { + if (whereValue === null) return recordValue === null || recordValue === undefined; + if (typeof whereValue === 'object' && typeof recordValue === 'object') { + if (whereValue === null || recordValue === null) return whereValue === recordValue; + const a = whereValue as Record; + const b = recordValue as Record; + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + for (const key of keys) { + if (a[key] !== b[key]) return false; + } + return true; + } + return whereValue === recordValue; +} + +function readPath(record: unknown, path: string): unknown { + const parts = path.split('.'); + let cursor: unknown = record; + for (const part of parts) { + if (cursor === null || cursor === undefined) return undefined; + cursor = (cursor as Record)[part]; + } + return cursor; +} + +function whereMatches( + record: T, + where: Readonly>, + aliases: WhereAliasMap +): boolean { + for (const [key, expected] of Object.entries(where)) { + if (expected === undefined) continue; + const path = aliases[key] ?? key; + const actual = readPath(record, path); + if (!whereValueMatches(expected, actual)) return false; + } + return true; +} + +/** + * Build a typed `AuthRepository` over a `Map`. The + * `idOf` selector lets each record kind name its primary-key field — + * `id` for most, `sessSessionId` for session bindings. + */ +function createMapRepository( + map: Map, + idOf: (record: T) => string, + aliases: WhereAliasMap = {} +): AuthRepository { + return { + async insert(record) { + map.set(idOf(record), record); + return record; + }, + async update(where, patch) { + for (const [key, value] of map) { + if (whereMatches(value, where, aliases)) { + map.set(key, { ...value, ...patch }); + } + } + }, + async findOne(where) { + for (const value of map.values()) { + if (whereMatches(value, where, aliases)) return value; + } + return null; + }, + async findMany(where) { + const out: T[] = []; + for (const value of map.values()) { + if (whereMatches(value, where, aliases)) out.push(value); + } + return out; + } + }; +} + +/** + * Build the seven typed repos plus a transaction runner. The runner + * is sequential — sufficient to exercise the adapter's + * `repos.transaction(...)` call sites; concurrency-test fakes belong + * in their own module. + */ +export function createMemoryAuthDbRepositories( + store: MemoryAuthDbStore = createMemoryAuthDbStore() +): { store: MemoryAuthDbStore; repos: AuthDbRepositories } { + const repos: AuthDbRepositories = { + credentials: createMapRepository( + store.credentials, + (r) => r.id, + { tenantId: 'actorRef.tenantId' } + ), + flows: createMapRepository( + store.flows, + (r) => r.id, + { flowId: 'id' } + ), + linkedAccounts: createMapRepository( + store.linkedAccounts, + (r) => r.id, + { tenantId: 'actorRef.tenantId' } + ), + devices: createMapRepository(store.devices, (r) => r.id), + sessionBindings: createMapRepository( + store.sessionBindings, + (r) => r.sessSessionId + ), + refreshFamilies: createMapRepository(store.refreshFamilies, (r) => r.id), + refreshTokens: createMapRepository(store.refreshTokens, (r) => r.id), + async transaction(run) { + store.transactionDepth += 1; + store.transactionCount += 1; + try { + return await run(); + } finally { + store.transactionDepth -= 1; + } + } + }; + return { store, repos }; +}