From 6fad029fed97a162b0cacebbf0eeb9400b05e2be Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Fri, 20 Feb 2026 23:01:30 +0100 Subject: [PATCH] feat(auth): verify DID documents on authenticated requests Add DID document verification to the auth middleware so that deactivated or tombstoned DIDs are rejected instead of being trusted for the full session lifetime. - New DidDocumentVerifier service resolves did:plc via PLC directory with Valkey cache (1h soft / 2h hard TTL, SWR pattern for background refresh) - Auth middleware now calls verifier after token validation; requireAuth rejects inactive DIDs (401 or 502), optionalAuth continues unauthenticated - did:web DIDs pass through without PLC lookup - Fail-closed on resolution failure with no cached data - 30 new/updated tests covering all DID verification scenarios --- src/app.ts | 6 +- src/auth/middleware.ts | 56 +++- src/lib/did-document-verifier.ts | 148 ++++++++ tests/unit/auth/middleware.test.ts | 108 +++++- tests/unit/lib/did-document-verifier.test.ts | 334 +++++++++++++++++++ tests/unit/routes/setup.test.ts | 2 + 6 files changed, 637 insertions(+), 17 deletions(-) create mode 100644 src/lib/did-document-verifier.ts create mode 100644 tests/unit/lib/did-document-verifier.test.ts diff --git a/src/app.ts b/src/app.ts index ae6eac1..7402a4e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -46,6 +46,7 @@ import type { SetupService } from './setup/service.js' import { createPlcDidService } from './services/plc-did.js' import { createHandleResolver } from './lib/handle-resolver.js' import type { HandleResolver } from './lib/handle-resolver.js' +import { createDidDocumentVerifier } from './lib/did-document-verifier.js' import { createProfileSyncService } from './services/profile-sync.js' import type { ProfileSyncService } from './services/profile-sync.js' import { createLocalStorage } from './lib/storage.js' @@ -171,9 +172,12 @@ export async function buildApp(env: Env) { }) app.decorate('sessionService', sessionService) + // DID document verifier (checks DID is still active via PLC directory, cached in Valkey) + const didVerifier = createDidDocumentVerifier(cache, app.log) + // Auth middleware (request decoration must happen before hooks can set the property) app.decorateRequest('user', undefined as RequestUser | undefined) - const authMiddleware = createAuthMiddleware(sessionService, app.log) + const authMiddleware = createAuthMiddleware(sessionService, didVerifier, app.log) app.decorate('authMiddleware', authMiddleware) // Handle resolver (DID -> handle, with cache) diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index dbc6ba3..3e982d8 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,5 +1,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify' import type { SessionService } from './session.js' +import type { DidDocumentVerifier, DidVerificationResult } from '../lib/did-document-verifier.js' import type { Logger } from '../lib/logger.js' // --------------------------------------------------------------------------- @@ -52,29 +53,32 @@ function extractBearerToken(request: FastifyRequest): string | undefined { return token } +/** Check if a DID verification failure is a transient resolution error. */ +function isResolutionFailure(result: DidVerificationResult): boolean { + return !result.active && result.reason === 'DID document resolution failed' +} + // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- -// TODO(self-hosting): Add DID document verification with 1-hour cache TTL. (#37) -// Currently trusts the DID from the session. Full verification requires -// PLC directory / DNS resolution (see standards/backend.md). -// Not needed for single-instance MVP (trusted Valkey on same host). - /** * Create auth middleware hooks for Fastify route preHandler. * * @param sessionService - Session service for token validation + * @param didVerifier - DID document verifier for checking DID status * @param logger - Pino logger instance * @returns Object with requireAuth and optionalAuth hooks */ export function createAuthMiddleware( sessionService: SessionService, + didVerifier: DidDocumentVerifier, logger: Logger ): AuthMiddleware { /** * Require authentication. Returns 401 if no valid token, 502 if service error. * On success, sets `request.user` with the authenticated user info. + * Verifies the DID document is still active via the PLC directory (cached). */ async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { const token = extractBearerToken(request) @@ -90,6 +94,21 @@ export function createAuthMiddleware( return } + // Verify DID document is still active + const didResult = await didVerifier.verify(session.did) + if (!didResult.active) { + if (isResolutionFailure(didResult)) { + // Transient failure with no cached data -- fail closed + logger.error({ did: session.did, reason: didResult.reason }, 'DID verification failed') + await reply.status(502).send({ error: 'Service temporarily unavailable' }) + } else { + // DID is definitively deactivated/tombstoned/not found + logger.warn({ did: session.did, reason: didResult.reason }, 'DID is no longer active') + await reply.status(401).send({ error: 'DID is no longer active' }) + } + return + } + request.user = { did: session.did, handle: session.handle, @@ -103,7 +122,8 @@ export function createAuthMiddleware( /** * Optional authentication. If a valid token is present, sets `request.user`. - * If no token, invalid token, or service error: continues with `request.user` undefined. + * If no token, invalid token, DID inactive, or service error: continues + * with `request.user` undefined. */ async function optionalAuth(request: FastifyRequest, _reply: FastifyReply): Promise { const token = extractBearerToken(request) @@ -113,12 +133,24 @@ export function createAuthMiddleware( try { const session = await sessionService.validateAccessToken(token) - if (session) { - request.user = { - did: session.did, - handle: session.handle, - sid: session.sid, - } + if (!session) { + return + } + + // Verify DID document is still active + const didResult = await didVerifier.verify(session.did) + if (!didResult.active) { + logger.warn( + { did: session.did, reason: didResult.reason }, + 'DID verification failed in optionalAuth, continuing unauthenticated' + ) + return + } + + request.user = { + did: session.did, + handle: session.handle, + sid: session.sid, } } catch (err: unknown) { logger.warn({ err }, 'Token validation failed in optionalAuth, continuing unauthenticated') diff --git a/src/lib/did-document-verifier.ts b/src/lib/did-document-verifier.ts new file mode 100644 index 0000000..d750428 --- /dev/null +++ b/src/lib/did-document-verifier.ts @@ -0,0 +1,148 @@ +import type { Cache } from '../cache/index.js' +import type { Logger } from './logger.js' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const DID_DOC_CACHE_PREFIX = 'barazo:did-doc:' +/** Soft TTL in seconds -- triggers background refresh after this. */ +export const DID_DOC_SOFT_TTL = 3600 // 1 hour +/** Hard TTL in seconds -- Valkey key expiry. */ +export const DID_DOC_HARD_TTL = 7200 // 2 hours + +const PLC_DIRECTORY_URL = 'https://plc.directory' +const PLC_FETCH_TIMEOUT = 5000 // 5 seconds + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DidVerificationResult = { active: true } | { active: false; reason: string } + +interface CachedDidEntry { + active: boolean + reason?: string + resolvedAt: number +} + +export interface DidDocumentVerifier { + /** Verify that a DID is still active. */ + verify(did: string): Promise +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createDidDocumentVerifier(cache: Cache, logger: Logger): DidDocumentVerifier { + /** + * Resolve a DID document from PLC directory and return whether it's active. + */ + async function resolveFromPlc(did: string): Promise { + const url = `${PLC_DIRECTORY_URL}/${did}` + + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(PLC_FETCH_TIMEOUT), + }) + + if (response.ok) { + return { active: true } + } + + if (response.status === 410) { + return { active: false, reason: 'DID has been tombstoned' } + } + + if (response.status === 404) { + return { active: false, reason: 'DID not found in PLC directory' } + } + + // Unexpected status -- treat as resolution failure + logger.warn({ did, status: response.status }, 'Unexpected PLC directory response') + throw new Error(`PLC directory returned ${String(response.status)}`) + } + + /** + * Cache a verification result with hard TTL. + */ + async function cacheResult(did: string, result: DidVerificationResult): Promise { + const entry: CachedDidEntry = { + active: result.active, + resolvedAt: Date.now(), + ...(!result.active && { reason: (result as { reason: string }).reason }), + } + + await cache.set(`${DID_DOC_CACHE_PREFIX}${did}`, JSON.stringify(entry), 'EX', DID_DOC_HARD_TTL) + } + + /** + * Trigger a non-blocking background refresh for a DID. + */ + function backgroundRefresh(did: string): void { + resolveFromPlc(did) + .then(async (result) => { + await cacheResult(did, result) + logger.debug({ did }, 'Background DID document refresh completed') + }) + .catch((err: unknown) => { + logger.warn({ err, did }, 'Background DID document refresh failed') + }) + } + + async function verify(did: string): Promise { + // did:web -- skip PLC verification (PLC only handles did:plc) + if (!did.startsWith('did:plc:')) { + return { active: true } + } + + // 1. Try cache + let cachedEntry: CachedDidEntry | undefined + try { + const raw = await cache.get(`${DID_DOC_CACHE_PREFIX}${did}`) + if (raw !== null) { + cachedEntry = JSON.parse(raw) as CachedDidEntry + + const age = Date.now() - cachedEntry.resolvedAt + const isPastSoftTtl = age > DID_DOC_SOFT_TTL * 1000 + + if (!isPastSoftTtl) { + // Fresh cache hit -- return immediately + if (cachedEntry.active) { + return { active: true } + } + return { active: false, reason: cachedEntry.reason ?? 'DID is not active' } + } + + // Past soft TTL -- serve stale and trigger background refresh + backgroundRefresh(did) + + if (cachedEntry.active) { + return { active: true } + } + return { active: false, reason: cachedEntry.reason ?? 'DID is not active' } + } + } catch (err: unknown) { + logger.warn({ err, did }, 'DID document cache read failed') + // Fall through to PLC directory resolution + } + + // 2. Cache miss -- resolve from PLC directory + try { + const result = await resolveFromPlc(did) + + // Cache the result (fire-and-forget on failure) + cacheResult(did, result).catch((err: unknown) => { + logger.warn({ err, did }, 'Failed to cache DID verification result') + }) + + return result + } catch (err: unknown) { + logger.error({ err, did }, 'DID document resolution failed') + return { active: false, reason: 'DID document resolution failed' } + } + } + + return { verify } +} diff --git a/tests/unit/auth/middleware.test.ts b/tests/unit/auth/middleware.test.ts index dddebdc..f49f1aa 100644 --- a/tests/unit/auth/middleware.test.ts +++ b/tests/unit/auth/middleware.test.ts @@ -4,6 +4,7 @@ import type { FastifyInstance } from 'fastify' import { createAuthMiddleware } from '../../../src/auth/middleware.js' import type { RequestUser } from '../../../src/auth/middleware.js' import type { SessionService, Session } from '../../../src/auth/session.js' +import type { DidDocumentVerifier } from '../../../src/lib/did-document-verifier.js' import type { Logger } from '../../../src/lib/logger.js' // --------------------------------------------------------------------------- @@ -11,6 +12,8 @@ import type { Logger } from '../../../src/lib/logger.js' // --------------------------------------------------------------------------- const validateAccessTokenFn = vi.fn<(...args: unknown[]) => Promise>() +const verifyDidFn = + vi.fn<(...args: unknown[]) => Promise<{ active: true } | { active: false; reason: string }>>() function createMockSessionService(): SessionService { return { @@ -22,6 +25,12 @@ function createMockSessionService(): SessionService { } } +function createMockDidVerifier(): DidDocumentVerifier { + return { + verify: verifyDidFn, + } +} + // Logger mock functions const logErrorFn = vi.fn() const logWarnFn = vi.fn() @@ -64,8 +73,9 @@ describe('requireAuth middleware', () => { beforeAll(async () => { const mockSessionService = createMockSessionService() const mockLogger = createMockLogger() + const mockDidVerifier = createMockDidVerifier() - const { requireAuth } = createAuthMiddleware(mockSessionService, mockLogger) + const { requireAuth } = createAuthMiddleware(mockSessionService, mockDidVerifier, mockLogger) app = Fastify({ logger: false }) @@ -85,6 +95,8 @@ describe('requireAuth middleware', () => { beforeEach(() => { vi.clearAllMocks() + // Default: DID verification passes + verifyDidFn.mockResolvedValue({ active: true }) }) it('returns 401 for missing Authorization header', async () => { @@ -133,8 +145,9 @@ describe('requireAuth middleware', () => { expect(validateAccessTokenFn).toHaveBeenCalledWith(VALID_TOKEN) }) - it('sets request.user and returns 200 for valid token', async () => { + it('sets request.user and returns 200 for valid token with active DID', async () => { validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) + verifyDidFn.mockResolvedValueOnce({ active: true }) const response = await app.inject({ method: 'GET', @@ -151,6 +164,59 @@ describe('requireAuth middleware', () => { sid: VALID_SESSION.sid, }) expect(validateAccessTokenFn).toHaveBeenCalledWith(VALID_TOKEN) + expect(verifyDidFn).toHaveBeenCalledWith(VALID_SESSION.did) + }) + + it('returns 401 when DID is deactivated/tombstoned', async () => { + validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) + verifyDidFn.mockResolvedValueOnce({ active: false, reason: 'DID has been tombstoned' }) + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${VALID_TOKEN}` }, + }) + + expect(response.statusCode).toBe(401) + expect(response.json<{ error: string }>()).toStrictEqual({ + error: 'DID is no longer active', + }) + }) + + it('returns 502 when DID verification fails with resolution error', async () => { + validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) + verifyDidFn.mockResolvedValueOnce({ + active: false, + reason: 'DID document resolution failed', + }) + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${VALID_TOKEN}` }, + }) + + expect(response.statusCode).toBe(502) + expect(response.json<{ error: string }>()).toStrictEqual({ + error: 'Service temporarily unavailable', + }) + }) + + it('returns 502 when DID verifier throws', async () => { + validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) + verifyDidFn.mockRejectedValueOnce(new Error('Unexpected error')) + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${VALID_TOKEN}` }, + }) + + expect(response.statusCode).toBe(502) + expect(response.json<{ error: string }>()).toStrictEqual({ + error: 'Service temporarily unavailable', + }) + expect(logErrorFn).toHaveBeenCalledOnce() }) it('returns 502 when sessionService throws', async () => { @@ -180,8 +246,9 @@ describe('optionalAuth middleware', () => { beforeAll(async () => { const mockSessionService = createMockSessionService() const mockLogger = createMockLogger() + const mockDidVerifier = createMockDidVerifier() - const { optionalAuth } = createAuthMiddleware(mockSessionService, mockLogger) + const { optionalAuth } = createAuthMiddleware(mockSessionService, mockDidVerifier, mockLogger) app = Fastify({ logger: false }) @@ -201,10 +268,13 @@ describe('optionalAuth middleware', () => { beforeEach(() => { vi.clearAllMocks() + // Default: DID verification passes + verifyDidFn.mockResolvedValue({ active: true }) }) - it('sets request.user for valid token', async () => { + it('sets request.user for valid token with active DID', async () => { validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) + verifyDidFn.mockResolvedValueOnce({ active: true }) const response = await app.inject({ method: 'GET', @@ -245,6 +315,36 @@ describe('optionalAuth middleware', () => { expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) }) + it('continues with request.user undefined when DID is deactivated', async () => { + validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) + verifyDidFn.mockResolvedValueOnce({ active: false, reason: 'DID has been tombstoned' }) + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${VALID_TOKEN}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) + expect(logWarnFn).toHaveBeenCalledOnce() + }) + + it('continues with request.user undefined when DID verifier throws', async () => { + validateAccessTokenFn.mockResolvedValueOnce(VALID_SESSION) + verifyDidFn.mockRejectedValueOnce(new Error('Unexpected error')) + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${VALID_TOKEN}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json<{ user: null }>()).toStrictEqual({ user: null }) + expect(logWarnFn).toHaveBeenCalledOnce() + }) + it('continues with request.user undefined when sessionService throws and logs warning', async () => { validateAccessTokenFn.mockRejectedValueOnce(new Error('Valkey connection lost')) diff --git a/tests/unit/lib/did-document-verifier.test.ts b/tests/unit/lib/did-document-verifier.test.ts new file mode 100644 index 0000000..b8ea185 --- /dev/null +++ b/tests/unit/lib/did-document-verifier.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + createDidDocumentVerifier, + DID_DOC_CACHE_PREFIX, + DID_DOC_HARD_TTL, + DID_DOC_SOFT_TTL, +} from '../../../src/lib/did-document-verifier.js' +import type { DidDocumentVerifier } from '../../../src/lib/did-document-verifier.js' +import type { Logger } from '../../../src/lib/logger.js' + +// --------------------------------------------------------------------------- +// Mock logger +// --------------------------------------------------------------------------- + +function createMockLogger(): Logger { + return { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + child: vi.fn(), + silent: vi.fn(), + level: 'silent', + } as unknown as Logger +} + +// --------------------------------------------------------------------------- +// Mock cache +// --------------------------------------------------------------------------- + +function createMockCache() { + return { + get: vi.fn<(...args: unknown[]) => Promise>(), + set: vi.fn<(...args: unknown[]) => Promise>(), + } +} + +type MockCache = ReturnType + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const TEST_DID = 'did:plc:abc123def456' +const TEST_DID_WEB = 'did:web:example.com' + +function activeDidDoc() { + return { + id: TEST_DID, + alsoKnownAs: ['at://alice.bsky.social'], + verificationMethods: { atproto: 'did:key:z123' }, + rotationKeys: ['did:key:z456'], + services: { + atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: 'https://pds.example.com' }, + }, + } +} + +function cachedEntry(overrides: Record = {}) { + return JSON.stringify({ + active: true, + resolvedAt: Date.now() - 30 * 60 * 1000, // 30 min ago (within soft TTL) + ...overrides, + }) +} + +function staleCachedEntry() { + return JSON.stringify({ + active: true, + resolvedAt: Date.now() - 70 * 60 * 1000, // 70 min ago (past soft TTL) + }) +} + +function deactivatedCachedEntry() { + return JSON.stringify({ + active: false, + reason: 'tombstoned', + resolvedAt: Date.now() - 10 * 60 * 1000, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('DidDocumentVerifier', () => { + let verifier: DidDocumentVerifier + let mockCache: MockCache + let mockLogger: Logger + let originalFetch: typeof globalThis.fetch + let mockFetch: ReturnType> + + beforeEach(() => { + mockCache = createMockCache() + mockLogger = createMockLogger() + verifier = createDidDocumentVerifier(mockCache as never, mockLogger) + + originalFetch = globalThis.fetch + mockFetch = vi.fn() + globalThis.fetch = mockFetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + // ========================================================================= + // Cache hit - active DID + // ========================================================================= + + describe('cache hit with active DID', () => { + it('returns active result without calling PLC directory', async () => { + mockCache.get.mockResolvedValueOnce(cachedEntry()) + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ active: true }) + expect(mockFetch).not.toHaveBeenCalled() + expect(mockCache.get).toHaveBeenCalledWith(`${DID_DOC_CACHE_PREFIX}${TEST_DID}`) + }) + }) + + // ========================================================================= + // Cache hit - deactivated DID + // ========================================================================= + + describe('cache hit with deactivated DID', () => { + it('returns inactive result', async () => { + mockCache.get.mockResolvedValueOnce(deactivatedCachedEntry()) + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ active: false, reason: 'tombstoned' }) + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + + // ========================================================================= + // Cache miss - successful PLC resolution + // ========================================================================= + + describe('cache miss with successful PLC resolution', () => { + it('resolves from PLC directory and caches the result', async () => { + mockCache.get.mockResolvedValueOnce(null) // cache miss + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(activeDidDoc()), { status: 200 })) + mockCache.set.mockResolvedValueOnce('OK') + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ active: true }) + + // Verify PLC directory was called + expect(mockFetch).toHaveBeenCalledOnce() + const [url] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe(`https://plc.directory/${TEST_DID}`) + + // Verify cache was populated with hard TTL + expect(mockCache.set).toHaveBeenCalledWith( + `${DID_DOC_CACHE_PREFIX}${TEST_DID}`, + expect.any(String) as string, + 'EX', + DID_DOC_HARD_TTL + ) + }) + }) + + // ========================================================================= + // Cache miss - tombstoned DID (410) + // ========================================================================= + + describe('cache miss with tombstoned DID', () => { + it('rejects with tombstoned reason and caches the result', async () => { + mockCache.get.mockResolvedValueOnce(null) + mockFetch.mockResolvedValueOnce(new Response('Gone', { status: 410 })) + mockCache.set.mockResolvedValueOnce('OK') + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ active: false, reason: 'DID has been tombstoned' }) + + // Cache the tombstoned status to avoid repeated lookups + expect(mockCache.set).toHaveBeenCalledOnce() + }) + }) + + // ========================================================================= + // Cache miss - DID not found (404) + // ========================================================================= + + describe('cache miss with DID not found', () => { + it('rejects with not-found reason', async () => { + mockCache.get.mockResolvedValueOnce(null) + mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })) + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ active: false, reason: 'DID not found in PLC directory' }) + }) + }) + + // ========================================================================= + // Resolution failure - no cache (fail closed) + // ========================================================================= + + describe('resolution failure with no cache', () => { + it('rejects when PLC directory is unreachable and no cache exists', async () => { + mockCache.get.mockResolvedValueOnce(null) + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ + active: false, + reason: 'DID document resolution failed', + }) + }) + }) + + // ========================================================================= + // Resolution failure - stale cache available (serve stale) + // ========================================================================= + + describe('resolution failure with stale cache available', () => { + it('uses stale cached value when PLC directory fails', async () => { + // First call: cache returns stale entry (past soft TTL but before hard TTL) + // The verifier should try to refresh, fail, then serve stale + mockCache.get.mockResolvedValueOnce(staleCachedEntry()) + // Background refresh will fail + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const result = await verifier.verify(TEST_DID) + + // Should still return active from stale cache + expect(result).toStrictEqual({ active: true }) + }) + }) + + // ========================================================================= + // Cache error - fallback to PLC directory + // ========================================================================= + + describe('cache error', () => { + it('falls back to PLC directory when cache read fails', async () => { + mockCache.get.mockRejectedValueOnce(new Error('Valkey down')) + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(activeDidDoc()), { status: 200 })) + mockCache.set.mockRejectedValueOnce(new Error('Valkey down')) // cache write also fails + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ active: true }) + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('rejects when both cache and PLC directory fail', async () => { + mockCache.get.mockRejectedValueOnce(new Error('Valkey down')) + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ + active: false, + reason: 'DID document resolution failed', + }) + }) + }) + + // ========================================================================= + // did:web passthrough + // ========================================================================= + + describe('did:web handling', () => { + it('allows did:web DIDs without PLC lookup', async () => { + const result = await verifier.verify(TEST_DID_WEB) + + expect(result).toStrictEqual({ active: true }) + expect(mockFetch).not.toHaveBeenCalled() + expect(mockCache.get).not.toHaveBeenCalled() + }) + }) + + // ========================================================================= + // Background refresh on soft TTL expiry + // ========================================================================= + + describe('background refresh', () => { + it('triggers background refresh when cached entry is past soft TTL', async () => { + mockCache.get.mockResolvedValueOnce(staleCachedEntry()) + // Background refresh succeeds + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(activeDidDoc()), { status: 200 })) + mockCache.set.mockResolvedValueOnce('OK') + + const result = await verifier.verify(TEST_DID) + + // Returns immediately from stale cache + expect(result).toStrictEqual({ active: true }) + + // Wait for background refresh to complete + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalledOnce() + }) + }) + + it('does not trigger background refresh when cached entry is within soft TTL', async () => { + mockCache.get.mockResolvedValueOnce(cachedEntry()) + + const result = await verifier.verify(TEST_DID) + + expect(result).toStrictEqual({ active: true }) + + // No fetch should have been triggered + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + + // ========================================================================= + // Constants exported correctly + // ========================================================================= + + describe('exported constants', () => { + it('has 1-hour soft TTL', () => { + expect(DID_DOC_SOFT_TTL).toBe(3600) + }) + + it('has 2-hour hard TTL', () => { + expect(DID_DOC_HARD_TTL).toBe(7200) + }) + + it('has correct cache prefix', () => { + expect(DID_DOC_CACHE_PREFIX).toBe('barazo:did-doc:') + }) + }) +}) diff --git a/tests/unit/routes/setup.test.ts b/tests/unit/routes/setup.test.ts index 8563182..995cc2d 100644 --- a/tests/unit/routes/setup.test.ts +++ b/tests/unit/routes/setup.test.ts @@ -73,8 +73,10 @@ describe('setup routes', () => { silent: vi.fn(), level: 'silent', } + const mockDidVerifier = { verify: vi.fn().mockResolvedValue({ active: true }) } const authMiddleware: AuthMiddleware = createAuthMiddleware( mockSessionService, + mockDidVerifier, mockLogger as never ) -- 2.51.2