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
parent
331cc72674
commit
94ef90fdcf
@ -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…
Reference in new issue