Barazo AppView backend barazo.forum
at main 626 lines 21 kB view raw
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})