Barazo AppView backend barazo.forum
at main 334 lines 12 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 2import { 3 createDidDocumentVerifier, 4 DID_DOC_CACHE_PREFIX, 5 DID_DOC_HARD_TTL, 6 DID_DOC_SOFT_TTL, 7} from '../../../src/lib/did-document-verifier.js' 8import type { DidDocumentVerifier } from '../../../src/lib/did-document-verifier.js' 9import type { Logger } from '../../../src/lib/logger.js' 10 11// --------------------------------------------------------------------------- 12// Mock logger 13// --------------------------------------------------------------------------- 14 15function createMockLogger(): Logger { 16 return { 17 info: vi.fn(), 18 error: vi.fn(), 19 warn: vi.fn(), 20 debug: vi.fn(), 21 fatal: vi.fn(), 22 trace: vi.fn(), 23 child: vi.fn(), 24 silent: vi.fn(), 25 level: 'silent', 26 } as unknown as Logger 27} 28 29// --------------------------------------------------------------------------- 30// Mock cache 31// --------------------------------------------------------------------------- 32 33function createMockCache() { 34 return { 35 get: vi.fn<(...args: unknown[]) => Promise<string | null>>(), 36 set: vi.fn<(...args: unknown[]) => Promise<string>>(), 37 } 38} 39 40type MockCache = ReturnType<typeof createMockCache> 41 42// --------------------------------------------------------------------------- 43// Fixtures 44// --------------------------------------------------------------------------- 45 46const TEST_DID = 'did:plc:abc123def456' 47const TEST_DID_WEB = 'did:web:example.com' 48 49function activeDidDoc() { 50 return { 51 id: TEST_DID, 52 alsoKnownAs: ['at://jay.bsky.team'], 53 verificationMethods: { atproto: 'did:key:z123' }, 54 rotationKeys: ['did:key:z456'], 55 services: { 56 atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: 'https://pds.example.com' }, 57 }, 58 } 59} 60 61function cachedEntry(overrides: Record<string, unknown> = {}) { 62 return JSON.stringify({ 63 active: true, 64 resolvedAt: Date.now() - 30 * 60 * 1000, // 30 min ago (within soft TTL) 65 ...overrides, 66 }) 67} 68 69function staleCachedEntry() { 70 return JSON.stringify({ 71 active: true, 72 resolvedAt: Date.now() - 70 * 60 * 1000, // 70 min ago (past soft TTL) 73 }) 74} 75 76function deactivatedCachedEntry() { 77 return JSON.stringify({ 78 active: false, 79 reason: 'tombstoned', 80 resolvedAt: Date.now() - 10 * 60 * 1000, 81 }) 82} 83 84// --------------------------------------------------------------------------- 85// Tests 86// --------------------------------------------------------------------------- 87 88describe('DidDocumentVerifier', () => { 89 let verifier: DidDocumentVerifier 90 let mockCache: MockCache 91 let mockLogger: Logger 92 let originalFetch: typeof globalThis.fetch 93 let mockFetch: ReturnType<typeof vi.fn<typeof globalThis.fetch>> 94 95 beforeEach(() => { 96 mockCache = createMockCache() 97 mockLogger = createMockLogger() 98 verifier = createDidDocumentVerifier(mockCache as never, mockLogger) 99 100 originalFetch = globalThis.fetch 101 mockFetch = vi.fn<typeof globalThis.fetch>() 102 globalThis.fetch = mockFetch 103 }) 104 105 afterEach(() => { 106 globalThis.fetch = originalFetch 107 }) 108 109 // ========================================================================= 110 // Cache hit - active DID 111 // ========================================================================= 112 113 describe('cache hit with active DID', () => { 114 it('returns active result without calling PLC directory', async () => { 115 mockCache.get.mockResolvedValueOnce(cachedEntry()) 116 117 const result = await verifier.verify(TEST_DID) 118 119 expect(result).toStrictEqual({ active: true }) 120 expect(mockFetch).not.toHaveBeenCalled() 121 expect(mockCache.get).toHaveBeenCalledWith(`${DID_DOC_CACHE_PREFIX}${TEST_DID}`) 122 }) 123 }) 124 125 // ========================================================================= 126 // Cache hit - deactivated DID 127 // ========================================================================= 128 129 describe('cache hit with deactivated DID', () => { 130 it('returns inactive result', async () => { 131 mockCache.get.mockResolvedValueOnce(deactivatedCachedEntry()) 132 133 const result = await verifier.verify(TEST_DID) 134 135 expect(result).toStrictEqual({ active: false, reason: 'tombstoned' }) 136 expect(mockFetch).not.toHaveBeenCalled() 137 }) 138 }) 139 140 // ========================================================================= 141 // Cache miss - successful PLC resolution 142 // ========================================================================= 143 144 describe('cache miss with successful PLC resolution', () => { 145 it('resolves from PLC directory and caches the result', async () => { 146 mockCache.get.mockResolvedValueOnce(null) // cache miss 147 mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(activeDidDoc()), { status: 200 })) 148 mockCache.set.mockResolvedValueOnce('OK') 149 150 const result = await verifier.verify(TEST_DID) 151 152 expect(result).toStrictEqual({ active: true }) 153 154 // Verify PLC directory was called 155 expect(mockFetch).toHaveBeenCalledOnce() 156 const [url] = mockFetch.mock.calls[0] as [string, RequestInit] 157 expect(url).toBe(`https://plc.directory/${TEST_DID}`) 158 159 // Verify cache was populated with hard TTL 160 expect(mockCache.set).toHaveBeenCalledWith( 161 `${DID_DOC_CACHE_PREFIX}${TEST_DID}`, 162 expect.any(String) as string, 163 'EX', 164 DID_DOC_HARD_TTL 165 ) 166 }) 167 }) 168 169 // ========================================================================= 170 // Cache miss - tombstoned DID (410) 171 // ========================================================================= 172 173 describe('cache miss with tombstoned DID', () => { 174 it('rejects with tombstoned reason and caches the result', async () => { 175 mockCache.get.mockResolvedValueOnce(null) 176 mockFetch.mockResolvedValueOnce(new Response('Gone', { status: 410 })) 177 mockCache.set.mockResolvedValueOnce('OK') 178 179 const result = await verifier.verify(TEST_DID) 180 181 expect(result).toStrictEqual({ active: false, reason: 'DID has been tombstoned' }) 182 183 // Cache the tombstoned status to avoid repeated lookups 184 expect(mockCache.set).toHaveBeenCalledOnce() 185 }) 186 }) 187 188 // ========================================================================= 189 // Cache miss - DID not found (404) 190 // ========================================================================= 191 192 describe('cache miss with DID not found', () => { 193 it('rejects with not-found reason', async () => { 194 mockCache.get.mockResolvedValueOnce(null) 195 mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })) 196 197 const result = await verifier.verify(TEST_DID) 198 199 expect(result).toStrictEqual({ active: false, reason: 'DID not found in PLC directory' }) 200 }) 201 }) 202 203 // ========================================================================= 204 // Resolution failure - no cache (fail closed) 205 // ========================================================================= 206 207 describe('resolution failure with no cache', () => { 208 it('rejects when PLC directory is unreachable and no cache exists', async () => { 209 mockCache.get.mockResolvedValueOnce(null) 210 mockFetch.mockRejectedValueOnce(new Error('Network error')) 211 212 const result = await verifier.verify(TEST_DID) 213 214 expect(result).toStrictEqual({ 215 active: false, 216 reason: 'DID document resolution failed', 217 }) 218 }) 219 }) 220 221 // ========================================================================= 222 // Resolution failure - stale cache available (serve stale) 223 // ========================================================================= 224 225 describe('resolution failure with stale cache available', () => { 226 it('uses stale cached value when PLC directory fails', async () => { 227 // First call: cache returns stale entry (past soft TTL but before hard TTL) 228 // The verifier should try to refresh, fail, then serve stale 229 mockCache.get.mockResolvedValueOnce(staleCachedEntry()) 230 // Background refresh will fail 231 mockFetch.mockRejectedValueOnce(new Error('Network error')) 232 233 const result = await verifier.verify(TEST_DID) 234 235 // Should still return active from stale cache 236 expect(result).toStrictEqual({ active: true }) 237 }) 238 }) 239 240 // ========================================================================= 241 // Cache error - fallback to PLC directory 242 // ========================================================================= 243 244 describe('cache error', () => { 245 it('falls back to PLC directory when cache read fails', async () => { 246 mockCache.get.mockRejectedValueOnce(new Error('Valkey down')) 247 mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(activeDidDoc()), { status: 200 })) 248 mockCache.set.mockRejectedValueOnce(new Error('Valkey down')) // cache write also fails 249 250 const result = await verifier.verify(TEST_DID) 251 252 expect(result).toStrictEqual({ active: true }) 253 expect(mockFetch).toHaveBeenCalledOnce() 254 }) 255 256 it('rejects when both cache and PLC directory fail', async () => { 257 mockCache.get.mockRejectedValueOnce(new Error('Valkey down')) 258 mockFetch.mockRejectedValueOnce(new Error('Network error')) 259 260 const result = await verifier.verify(TEST_DID) 261 262 expect(result).toStrictEqual({ 263 active: false, 264 reason: 'DID document resolution failed', 265 }) 266 }) 267 }) 268 269 // ========================================================================= 270 // did:web passthrough 271 // ========================================================================= 272 273 describe('did:web handling', () => { 274 it('allows did:web DIDs without PLC lookup', async () => { 275 const result = await verifier.verify(TEST_DID_WEB) 276 277 expect(result).toStrictEqual({ active: true }) 278 expect(mockFetch).not.toHaveBeenCalled() 279 expect(mockCache.get).not.toHaveBeenCalled() 280 }) 281 }) 282 283 // ========================================================================= 284 // Background refresh on soft TTL expiry 285 // ========================================================================= 286 287 describe('background refresh', () => { 288 it('triggers background refresh when cached entry is past soft TTL', async () => { 289 mockCache.get.mockResolvedValueOnce(staleCachedEntry()) 290 // Background refresh succeeds 291 mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(activeDidDoc()), { status: 200 })) 292 mockCache.set.mockResolvedValueOnce('OK') 293 294 const result = await verifier.verify(TEST_DID) 295 296 // Returns immediately from stale cache 297 expect(result).toStrictEqual({ active: true }) 298 299 // Wait for background refresh to complete 300 await vi.waitFor(() => { 301 expect(mockFetch).toHaveBeenCalledOnce() 302 }) 303 }) 304 305 it('does not trigger background refresh when cached entry is within soft TTL', async () => { 306 mockCache.get.mockResolvedValueOnce(cachedEntry()) 307 308 const result = await verifier.verify(TEST_DID) 309 310 expect(result).toStrictEqual({ active: true }) 311 312 // No fetch should have been triggered 313 expect(mockFetch).not.toHaveBeenCalled() 314 }) 315 }) 316 317 // ========================================================================= 318 // Constants exported correctly 319 // ========================================================================= 320 321 describe('exported constants', () => { 322 it('has 1-hour soft TTL', () => { 323 expect(DID_DOC_SOFT_TTL).toBe(3600) 324 }) 325 326 it('has 2-hour hard TTL', () => { 327 expect(DID_DOC_HARD_TTL).toBe(7200) 328 }) 329 330 it('has correct cache prefix', () => { 331 expect(DID_DOC_CACHE_PREFIX).toBe('barazo:did-doc:') 332 }) 333 }) 334})