Barazo AppView backend
barazo.forum
1import { describe, it, expect, vi, beforeEach } from 'vitest'
2import crypto from 'node:crypto'
3import { createSessionService } from '../../../src/auth/session.js'
4import type { SessionService, SessionConfig } from '../../../src/auth/session.js'
5import type { Cache } from '../../../src/cache/index.js'
6import type { Logger } from '../../../src/lib/logger.js'
7
8// ---------------------------------------------------------------------------
9// Helpers -- mirrors the mock pattern from oauth-stores.test.ts
10// ---------------------------------------------------------------------------
11
12function createMockCache() {
13 const setFn = vi.fn<(...args: unknown[]) => Promise<string>>().mockResolvedValue('OK')
14 const getFn = vi.fn<(...args: unknown[]) => Promise<string | null>>().mockResolvedValue(null)
15 const delFn = vi.fn<(...args: unknown[]) => Promise<number>>().mockResolvedValue(1)
16 const saddFn = vi.fn<(...args: unknown[]) => Promise<number>>().mockResolvedValue(1)
17 const smembersFn = vi.fn<(...args: unknown[]) => Promise<string[]>>().mockResolvedValue([])
18 const sremFn = vi.fn<(...args: unknown[]) => Promise<number>>().mockResolvedValue(1)
19 const expireFn = vi.fn<(...args: unknown[]) => Promise<number>>().mockResolvedValue(1)
20 return {
21 cache: {
22 set: setFn,
23 get: getFn,
24 del: delFn,
25 sadd: saddFn,
26 smembers: smembersFn,
27 srem: sremFn,
28 expire: expireFn,
29 } as unknown as Cache,
30 setFn,
31 getFn,
32 delFn,
33 saddFn,
34 smembersFn,
35 sremFn,
36 expireFn,
37 }
38}
39
40function createMockLogger() {
41 const debugFn = vi.fn()
42 const infoFn = vi.fn()
43 const warnFn = vi.fn()
44 const errorFn = vi.fn()
45 return {
46 logger: {
47 debug: debugFn,
48 info: infoFn,
49 warn: warnFn,
50 error: errorFn,
51 fatal: vi.fn(),
52 trace: vi.fn(),
53 child: vi.fn(),
54 } as unknown as Logger,
55 debugFn,
56 infoFn,
57 warnFn,
58 errorFn,
59 }
60}
61
62/** SHA-256 hash helper for test assertions */
63function sha256(value: string): string {
64 return crypto.createHash('sha256').update(value).digest('hex')
65}
66
67const defaultConfig: SessionConfig = {
68 sessionTtl: 604800, // 7 days
69 accessTokenTtl: 900, // 15 min
70}
71
72const testDid = 'did:plc:test-user-123'
73const testHandle = 'jay.bsky.team'
74
75/**
76 * Build a mock persisted session (as stored in Valkey).
77 * Uses accessTokenHash (never raw accessToken).
78 */
79function buildPersistedSession(
80 overrides: {
81 sid?: string
82 did?: string
83 handle?: string
84 accessTokenHash?: string
85 accessTokenExpiresAt?: number
86 createdAt?: number
87 } = {}
88) {
89 return {
90 sid: overrides.sid ?? 'a'.repeat(64),
91 did: overrides.did ?? testDid,
92 handle: overrides.handle ?? testHandle,
93 accessTokenHash: overrides.accessTokenHash ?? sha256('b'.repeat(64)),
94 accessTokenExpiresAt: overrides.accessTokenExpiresAt ?? Date.now() + 900_000,
95 createdAt: overrides.createdAt ?? Date.now(),
96 }
97}
98
99// ---------------------------------------------------------------------------
100// Tests
101// ---------------------------------------------------------------------------
102
103describe('SessionService', () => {
104 let _cache: Cache
105 let setFn: ReturnType<typeof createMockCache>['setFn']
106 let getFn: ReturnType<typeof createMockCache>['getFn']
107 let delFn: ReturnType<typeof createMockCache>['delFn']
108 let saddFn: ReturnType<typeof createMockCache>['saddFn']
109 let smembersFn: ReturnType<typeof createMockCache>['smembersFn']
110 let sremFn: ReturnType<typeof createMockCache>['sremFn']
111 let expireFn: ReturnType<typeof createMockCache>['expireFn']
112 let debugFn: ReturnType<typeof createMockLogger>['debugFn']
113 let errorFn: ReturnType<typeof createMockLogger>['errorFn']
114 let service: SessionService
115
116 beforeEach(() => {
117 const mocks = createMockCache()
118 const logMocks = createMockLogger()
119 _cache = mocks.cache
120 setFn = mocks.setFn
121 getFn = mocks.getFn
122 delFn = mocks.delFn
123 saddFn = mocks.saddFn
124 smembersFn = mocks.smembersFn
125 sremFn = mocks.sremFn
126 expireFn = mocks.expireFn
127 debugFn = logMocks.debugFn
128 errorFn = logMocks.errorFn
129 service = createSessionService(mocks.cache, logMocks.logger, defaultConfig)
130 })
131
132 // -------------------------------------------------------------------------
133 // createSession
134 // -------------------------------------------------------------------------
135 describe('createSession', () => {
136 it('creates session with valid did and handle', async () => {
137 const session = await service.createSession(testDid, testHandle)
138
139 expect(session.did).toBe(testDid)
140 expect(session.handle).toBe(testHandle)
141 })
142
143 it('generates a unique session ID (64 hex chars)', async () => {
144 const session = await service.createSession(testDid, testHandle)
145
146 expect(session.sid).toMatch(/^[a-f0-9]{64}$/)
147 })
148
149 it('generates a unique access token (64 hex chars)', async () => {
150 const session = await service.createSession(testDid, testHandle)
151
152 expect(session.accessToken).toMatch(/^[a-f0-9]{64}$/)
153 })
154
155 it('generates different IDs on each call', async () => {
156 const session1 = await service.createSession(testDid, testHandle)
157 const session2 = await service.createSession(testDid, testHandle)
158
159 expect(session1.sid).not.toBe(session2.sid)
160 expect(session1.accessToken).not.toBe(session2.accessToken)
161 })
162
163 it('stores session data with accessTokenHash (not raw token) in Valkey', async () => {
164 const session = await service.createSession(testDid, testHandle)
165 const tokenHash = sha256(session.accessToken)
166
167 // The persisted data should have accessTokenHash, NOT accessToken
168 const persisted = {
169 sid: session.sid,
170 did: session.did,
171 handle: session.handle,
172 accessTokenHash: tokenHash,
173 accessTokenExpiresAt: session.accessTokenExpiresAt,
174 createdAt: session.createdAt,
175 }
176
177 expect(setFn).toHaveBeenCalledWith(
178 `barazo:session:data:${session.sid}`,
179 JSON.stringify(persisted),
180 'EX',
181 604800
182 )
183 })
184
185 it('stores access token hash mapping with correct TTL', async () => {
186 const session = await service.createSession(testDid, testHandle)
187 const tokenHash = sha256(session.accessToken)
188
189 expect(setFn).toHaveBeenCalledWith(
190 `barazo:session:access:${tokenHash}`,
191 session.sid,
192 'EX',
193 900
194 )
195 })
196
197 it('adds session ID to DID index set', async () => {
198 const session = await service.createSession(testDid, testHandle)
199
200 expect(saddFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`, session.sid)
201 })
202
203 it('refreshes TTL on DID index set', async () => {
204 await service.createSession(testDid, testHandle)
205
206 expect(expireFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`, 604800)
207 })
208
209 it('sets accessTokenExpiresAt in the future', async () => {
210 const before = Date.now()
211 const session = await service.createSession(testDid, testHandle)
212 const after = Date.now()
213
214 // accessTokenExpiresAt should be ~900 seconds (15 min) from now
215 expect(session.accessTokenExpiresAt).toBeGreaterThanOrEqual(before + 900 * 1000)
216 expect(session.accessTokenExpiresAt).toBeLessThanOrEqual(after + 900 * 1000)
217 })
218
219 it('sets createdAt to approximately now', async () => {
220 const before = Date.now()
221 const session = await service.createSession(testDid, testHandle)
222 const after = Date.now()
223
224 expect(session.createdAt).toBeGreaterThanOrEqual(before)
225 expect(session.createdAt).toBeLessThanOrEqual(after)
226 })
227
228 it('returns SessionWithToken including both accessToken and accessTokenHash', async () => {
229 const session = await service.createSession(testDid, testHandle)
230
231 expect(session).toEqual(
232 expect.objectContaining({
233 sid: expect.stringMatching(/^[a-f0-9]{64}$/) as string,
234 did: testDid,
235 handle: testHandle,
236 accessToken: expect.stringMatching(/^[a-f0-9]{64}$/) as string,
237 accessTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/) as string,
238 accessTokenExpiresAt: expect.any(Number) as number,
239 createdAt: expect.any(Number) as number,
240 })
241 )
242
243 // accessTokenHash should be the SHA-256 of accessToken
244 expect(session.accessTokenHash).toBe(sha256(session.accessToken))
245 })
246
247 it('logs debug on success without raw tokens', async () => {
248 const session = await service.createSession(testDid, testHandle)
249
250 expect(debugFn).toHaveBeenCalledWith(
251 expect.objectContaining({
252 did: testDid,
253 sid: session.sid.slice(0, 8),
254 }),
255 'Session created'
256 )
257
258 // Verify no debug call contains the full access token
259 for (const call of debugFn.mock.calls) {
260 const logObj = JSON.stringify(call)
261 expect(logObj).not.toContain(session.accessToken)
262 }
263 })
264
265 it('logs error and rethrows on cache failure', async () => {
266 const error = new Error('Valkey connection refused')
267 setFn.mockRejectedValueOnce(error)
268
269 await expect(service.createSession(testDid, testHandle)).rejects.toThrow(
270 'Valkey connection refused'
271 )
272 expect(errorFn).toHaveBeenCalled()
273 })
274 })
275
276 // -------------------------------------------------------------------------
277 // validateAccessToken
278 // -------------------------------------------------------------------------
279 describe('validateAccessToken', () => {
280 it('returns session when access token is valid', async () => {
281 const rawToken = 'b'.repeat(64)
282 const tokenHash = sha256(rawToken)
283 const persisted = buildPersistedSession({ accessTokenHash: tokenHash })
284
285 // First get: access token hash → sid
286 getFn.mockResolvedValueOnce(persisted.sid)
287 // Second get: session data
288 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
289
290 const result = await service.validateAccessToken(rawToken)
291
292 expect(result).toEqual(persisted)
293 expect(getFn).toHaveBeenCalledWith(`barazo:session:access:${tokenHash}`)
294 expect(getFn).toHaveBeenCalledWith(`barazo:session:data:${persisted.sid}`)
295 })
296
297 it('returns undefined when access token not found', async () => {
298 getFn.mockResolvedValueOnce(null)
299
300 const result = await service.validateAccessToken('nonexistent-token')
301
302 expect(result).toBeUndefined()
303 })
304
305 it('returns undefined when session data not found (orphaned token)', async () => {
306 // Access token hash lookup returns a sid
307 getFn.mockResolvedValueOnce('a'.repeat(64))
308 // But session data is gone
309 getFn.mockResolvedValueOnce(null)
310
311 const result = await service.validateAccessToken('some-token')
312
313 expect(result).toBeUndefined()
314 })
315
316 it('never logs raw access tokens', async () => {
317 const rawToken = 'c'.repeat(64)
318 getFn.mockResolvedValueOnce(null)
319
320 await service.validateAccessToken(rawToken)
321
322 for (const call of debugFn.mock.calls) {
323 const logObj = JSON.stringify(call)
324 expect(logObj).not.toContain(rawToken)
325 }
326 })
327
328 it('logs error and rethrows on cache failure', async () => {
329 const error = new Error('Valkey timeout')
330 getFn.mockRejectedValueOnce(error)
331
332 await expect(service.validateAccessToken('some-token')).rejects.toThrow('Valkey timeout')
333 expect(errorFn).toHaveBeenCalled()
334 })
335 })
336
337 // -------------------------------------------------------------------------
338 // refreshSession
339 // -------------------------------------------------------------------------
340 describe('refreshSession', () => {
341 it('returns updated session with new access token', async () => {
342 const oldTokenHash = sha256('old-token-' + 'x'.repeat(54))
343 const persisted = buildPersistedSession({
344 accessTokenHash: oldTokenHash,
345 accessTokenExpiresAt: Date.now() - 1000,
346 createdAt: Date.now() - 600_000,
347 })
348
349 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
350
351 const result = await service.refreshSession(persisted.sid)
352
353 if (result === undefined) {
354 expect.fail('Expected session to be defined')
355 }
356 expect(result.sid).toBe(persisted.sid)
357 expect(result.did).toBe(testDid)
358 expect(result.handle).toBe(testHandle)
359 // New access token should be a fresh 64-char hex string
360 expect(result.accessToken).toMatch(/^[a-f0-9]{64}$/)
361 // New accessTokenHash should match the new access token
362 expect(result.accessTokenHash).toBe(sha256(result.accessToken))
363 expect(result.accessTokenHash).not.toBe(oldTokenHash)
364 // New expiry should be in the future
365 expect(result.accessTokenExpiresAt).toBeGreaterThan(Date.now())
366 // createdAt should remain the same
367 expect(result.createdAt).toBe(persisted.createdAt)
368 })
369
370 it('deletes old access token lookup', async () => {
371 const oldTokenHash = sha256('old-token-' + 'x'.repeat(54))
372 const persisted = buildPersistedSession({ accessTokenHash: oldTokenHash })
373
374 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
375
376 await service.refreshSession(persisted.sid)
377
378 expect(delFn).toHaveBeenCalledWith(`barazo:session:access:${oldTokenHash}`)
379 })
380
381 it('creates new access token lookup', async () => {
382 const persisted = buildPersistedSession()
383
384 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
385
386 const result = await service.refreshSession(persisted.sid)
387
388 if (result === undefined) {
389 expect.fail('Expected session to be defined')
390 }
391 const newTokenHash = sha256(result.accessToken)
392 expect(setFn).toHaveBeenCalledWith(
393 `barazo:session:access:${newTokenHash}`,
394 persisted.sid,
395 'EX',
396 900
397 )
398 })
399
400 it('updates session data with new accessTokenHash (not raw token)', async () => {
401 const persisted = buildPersistedSession()
402
403 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
404
405 const result = await service.refreshSession(persisted.sid)
406
407 if (result === undefined) {
408 expect.fail('Expected session to be defined')
409 }
410
411 // The persisted form should have accessTokenHash but NOT accessToken
412 const expectedPersisted = {
413 sid: result.sid,
414 did: result.did,
415 handle: result.handle,
416 accessTokenHash: result.accessTokenHash,
417 accessTokenExpiresAt: result.accessTokenExpiresAt,
418 createdAt: result.createdAt,
419 }
420
421 expect(setFn).toHaveBeenCalledWith(
422 `barazo:session:data:${persisted.sid}`,
423 JSON.stringify(expectedPersisted),
424 'EX',
425 604800
426 )
427 })
428
429 it('returns undefined when session ID not found', async () => {
430 getFn.mockResolvedValueOnce(null)
431
432 const result = await service.refreshSession('nonexistent-sid')
433
434 expect(result).toBeUndefined()
435 })
436
437 it('logs debug on success', async () => {
438 const persisted = buildPersistedSession()
439
440 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
441
442 await service.refreshSession(persisted.sid)
443
444 expect(debugFn).toHaveBeenCalledWith(
445 expect.objectContaining({
446 sid: persisted.sid.slice(0, 8),
447 }),
448 'Session refreshed'
449 )
450 })
451
452 it('logs error and rethrows on cache failure', async () => {
453 const error = new Error('Valkey error')
454 getFn.mockRejectedValueOnce(error)
455
456 await expect(service.refreshSession('some-sid')).rejects.toThrow('Valkey error')
457 expect(errorFn).toHaveBeenCalled()
458 })
459 })
460
461 // -------------------------------------------------------------------------
462 // deleteSession
463 // -------------------------------------------------------------------------
464 describe('deleteSession', () => {
465 it('deletes session data', async () => {
466 const persisted = buildPersistedSession()
467
468 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
469
470 await service.deleteSession(persisted.sid)
471
472 expect(delFn).toHaveBeenCalledWith(`barazo:session:data:${persisted.sid}`)
473 })
474
475 it('deletes access token lookup using stored hash', async () => {
476 const tokenHash = sha256('b'.repeat(64))
477 const persisted = buildPersistedSession({ accessTokenHash: tokenHash })
478
479 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
480
481 await service.deleteSession(persisted.sid)
482
483 expect(delFn).toHaveBeenCalledWith(`barazo:session:access:${tokenHash}`)
484 })
485
486 it('removes session ID from DID index set', async () => {
487 const persisted = buildPersistedSession()
488
489 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
490
491 await service.deleteSession(persisted.sid)
492
493 expect(sremFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`, persisted.sid)
494 })
495
496 it('does not throw when session does not exist', async () => {
497 getFn.mockResolvedValueOnce(null)
498
499 await expect(service.deleteSession('nonexistent-sid')).resolves.toBeUndefined()
500 })
501
502 it('logs debug on success', async () => {
503 const persisted = buildPersistedSession()
504
505 getFn.mockResolvedValueOnce(JSON.stringify(persisted))
506
507 await service.deleteSession(persisted.sid)
508
509 expect(debugFn).toHaveBeenCalledWith(
510 expect.objectContaining({ sid: persisted.sid.slice(0, 8) }),
511 'Session deleted'
512 )
513 })
514
515 it('logs error and rethrows on cache failure', async () => {
516 const error = new Error('Valkey error')
517 getFn.mockRejectedValueOnce(error)
518
519 await expect(service.deleteSession('some-sid')).rejects.toThrow('Valkey error')
520 expect(errorFn).toHaveBeenCalled()
521 })
522 })
523
524 // -------------------------------------------------------------------------
525 // deleteAllSessionsForDid
526 // -------------------------------------------------------------------------
527 describe('deleteAllSessionsForDid', () => {
528 it('deletes all sessions for a DID', async () => {
529 const sid1 = 'a'.repeat(64)
530 const sid2 = 'b'.repeat(64)
531
532 const session1 = buildPersistedSession({
533 sid: sid1,
534 accessTokenHash: sha256('c'.repeat(64)),
535 })
536 const session2 = buildPersistedSession({
537 sid: sid2,
538 accessTokenHash: sha256('d'.repeat(64)),
539 })
540
541 // smembers returns the set of session IDs
542 smembersFn.mockResolvedValueOnce([sid1, sid2])
543 // For each session, get returns the session data (for deleteSession)
544 getFn.mockResolvedValueOnce(JSON.stringify(session1))
545 getFn.mockResolvedValueOnce(JSON.stringify(session2))
546
547 const count = await service.deleteAllSessionsForDid(testDid)
548
549 expect(count).toBe(2)
550 expect(smembersFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`)
551 })
552
553 it('returns count of deleted sessions', async () => {
554 const sid1 = 'a'.repeat(64)
555 const sid2 = 'b'.repeat(64)
556 const sid3 = 'c'.repeat(64)
557
558 smembersFn.mockResolvedValueOnce([sid1, sid2, sid3])
559 getFn.mockResolvedValueOnce(
560 JSON.stringify(
561 buildPersistedSession({
562 sid: sid1,
563 accessTokenHash: sha256('x'.repeat(64)),
564 })
565 )
566 )
567 getFn.mockResolvedValueOnce(
568 JSON.stringify(
569 buildPersistedSession({
570 sid: sid2,
571 accessTokenHash: sha256('y'.repeat(64)),
572 })
573 )
574 )
575 getFn.mockResolvedValueOnce(
576 JSON.stringify(
577 buildPersistedSession({
578 sid: sid3,
579 accessTokenHash: sha256('z'.repeat(64)),
580 })
581 )
582 )
583
584 const count = await service.deleteAllSessionsForDid(testDid)
585
586 expect(count).toBe(3)
587 })
588
589 it('removes the DID index set', async () => {
590 smembersFn.mockResolvedValueOnce(['a'.repeat(64)])
591 getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession()))
592
593 await service.deleteAllSessionsForDid(testDid)
594
595 expect(delFn).toHaveBeenCalledWith(`barazo:session:did:${testDid}`)
596 })
597
598 it('returns 0 when DID has no sessions', async () => {
599 smembersFn.mockResolvedValueOnce([])
600
601 const count = await service.deleteAllSessionsForDid(testDid)
602
603 expect(count).toBe(0)
604 })
605
606 it('logs debug with count on success', async () => {
607 smembersFn.mockResolvedValueOnce(['a'.repeat(64)])
608 getFn.mockResolvedValueOnce(JSON.stringify(buildPersistedSession()))
609
610 await service.deleteAllSessionsForDid(testDid)
611
612 expect(debugFn).toHaveBeenCalledWith(
613 expect.objectContaining({ did: testDid, count: 1 }),
614 'All sessions deleted for DID'
615 )
616 })
617
618 it('logs error and rethrows on cache failure', async () => {
619 const error = new Error('Valkey error')
620 smembersFn.mockRejectedValueOnce(error)
621
622 await expect(service.deleteAllSessionsForDid(testDid)).rejects.toThrow('Valkey error')
623 expect(errorFn).toHaveBeenCalled()
624 })
625 })
626})