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 )