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