Barazo AppView backend
barazo.forum
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})