G4 — auth DB adapter contract tests + revokeFlows where-clause fix

Closes the codex P2/P3 audit item deferred on 2026-05-05.

`createDbAuthAdapter` is the auth artifact's pluggable persistence
facade. Until now it had no contract test — only the in-memory STORE
adapter was covered, and the DB facade is the path real production
deployments take. This commit adds:

- `test/db-adapter-fake.ts` — reference `AuthDbRepositories` fake, in
  memory, encoding the same where-clause conventions a real SQL repo
  must satisfy: `tenantId` aliases to `actorRef.tenantId` for
  credentials and linked accounts, `flowId` aliases to `id` for flows,
  `null` matches absent fields (SQL `IS NULL` semantics), `actorRef`
  compares deep instead of reference. The README documents the
  conventions; the fake makes them executable.

- `test/db-adapter-contract.test.ts` — 19 contract tests covering all
  22 `AuthStoreAdapter` methods through the adapter:
  credentials (create/find/update/markVerified, tenant isolation),
  flows (create/find/consume + the multi-row `revokeFlows` cascade),
  linked accounts (link/find with cross-tenant invisibility),
  devices (upsert insert/update split, list scoping, revoke cascade
  into bound sessions),
  session bindings (bind/find/revoke + the `revokeActorSessions`
  cascade that preserves already-revoked rows),
  refresh tokens (rotate inside a transaction, family revoke
  cascade across tokens, missing-token error path).

Real bug fixed during the contract suite: `revokeFlows` was
forwarding the full input (`{ tenantId, actorRef?, kind?, nowMs }`)
to `repos.flows.findMany(whereOf(input))`. A real SQL translator
turns `nowMs` into `WHERE now_ms = ?` — a column that doesn't exist,
producing a silent no-op. The adapter now constructs the where
clause explicitly, including only the predicate fields. The contract
test that revealed it (`revokeFlows scoped by tenant + actor + kind
cascades to all matching rows`) failed pre-fix and passes post-fix.

Suite: 1695 / 1695 passing (+19 tests, +1 file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 331cc72674
commit 94ef90fdcf

@ -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<Record<string, unknown>>);
await Promise.all(
flows.map((flow) => repos.flows.update({ id: flow.id }, { consumedAt: input.nowMs }))
);

@ -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<typeof createDbAuthAdapter>;
} {
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<string, unknown> = {}) {
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<typeof createDbAuthAdapter>, id: string, actor: AuthActorRef = ACTOR_A1): Promise<void> {
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();
});
});

@ -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<string, AuthCredentialRecord>;
readonly flows: Map<string, AuthFlowRecord>;
readonly linkedAccounts: Map<string, AuthLinkedAccountRecord>;
readonly devices: Map<string, AuthDeviceRecord>;
readonly sessionBindings: Map<string, AuthSessionBindingRecord>;
readonly refreshFamilies: Map<string, AuthRefreshFamilyRecord>;
readonly refreshTokens: Map<string, AuthRefreshTokenRecord>;
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<Record<string, string>>;
/**
* 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<string, unknown>;
const b = recordValue as Record<string, unknown>;
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<string, unknown>)[part];
}
return cursor;
}
function whereMatches<T>(
record: T,
where: Readonly<Record<string, unknown>>,
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<T>` over a `Map<string, T>`. The
* `idOf` selector lets each record kind name its primary-key field —
* `id` for most, `sessSessionId` for session bindings.
*/
function createMapRepository<T>(
map: Map<string, T>,
idOf: (record: T) => string,
aliases: WhereAliasMap = {}
): AuthRepository<T> {
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 };
}
Loading…
Cancel
Save

Powered by TurnKey Linux.