Refactor authentication to use centralized session manager

Extract session management logic from middleware into reusable SessionManager utility to improve reliability, reduce code duplication, and prevent thundering herd issues with jittered cache expiry.
This commit is contained in:
2025-07-11 14:43:50 -04:00
parent bf2361050f
commit c6f81a6686
8 changed files with 1051 additions and 139 deletions

View File

@@ -0,0 +1,268 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { SessionManager } from '~/server/utils/session-manager'
describe('SessionManager', () => {
let sessionManager: SessionManager
let mockFetch: any
beforeEach(() => {
sessionManager = SessionManager.getInstance()
sessionManager.clearCache()
mockFetch = vi.fn()
vi.clearAllMocks()
})
afterEach(() => {
sessionManager.clearCache()
})
describe('Request Deduplication', () => {
it('should deduplicate concurrent requests', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// Make multiple concurrent requests
const promises = [
sessionManager.checkSession({ fetchFn: mockFetch }),
sessionManager.checkSession({ fetchFn: mockFetch }),
sessionManager.checkSession({ fetchFn: mockFetch })
]
const results = await Promise.all(promises)
// Should only call fetch once
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(results.every(r => r.authenticated)).toBe(true)
expect(results.every(r => r.user.id === '123')).toBe(true)
})
it('should handle failed requests and not cache errors', async () => {
const error = new Error('Network error')
mockFetch.mockRejectedValue(error)
const result = await sessionManager.checkSession({ fetchFn: mockFetch })
expect(result.authenticated).toBe(false)
expect(result.reason).toBe('Network error')
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('should rate limit rapid successive requests', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// First request
await sessionManager.checkSession({ fetchFn: mockFetch })
// Immediate second request should use cache
const secondResult = await sessionManager.checkSession({ fetchFn: mockFetch })
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(secondResult.authenticated).toBe(true)
})
})
describe('Caching', () => {
it('should cache successful responses', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// First request
const firstResult = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'test-cache'
})
// Second request should use cache
const secondResult = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'test-cache'
})
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(firstResult.authenticated).toBe(true)
expect(secondResult.authenticated).toBe(true)
expect(secondResult.fromCache).toBe(true)
})
it('should respect cache expiry', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// First request with very short cache expiry
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'short-cache',
cacheExpiry: 100 // 100ms
})
// Wait for cache to expire
await new Promise(resolve => setTimeout(resolve, 150))
// Second request should make new fetch
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'short-cache',
cacheExpiry: 100
})
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('should use grace period for network errors', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValueOnce(mockResponse)
// First successful request
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'grace-test'
})
// Mock network error
const networkError = new Error('Network error')
networkError.code = 'ECONNREFUSED'
mockFetch.mockRejectedValue(networkError)
// Second request should use cached result due to network error
const result = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'grace-test'
})
expect(result.authenticated).toBe(true)
expect(result.reason).toBe('NETWORK_ERROR_CACHED')
})
})
describe('Session Validation', () => {
it('should validate session with fresh check', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
// Mock the fetch API
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse)
})
const result = await sessionManager.validateSession()
expect(result.authenticated).toBe(true)
expect(global.fetch).toHaveBeenCalledWith('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
})
it('should handle validation failure', async () => {
// Mock failed fetch
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401
})
const result = await sessionManager.validateSession()
expect(result.authenticated).toBe(false)
expect(result.reason).toBe('Session validation failed: 401')
})
})
describe('Cache Management', () => {
it('should clear cache', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// Cache some data
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'clear-test'
})
// Clear cache
sessionManager.clearCache()
// Next request should make fresh fetch
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'clear-test'
})
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('should provide cache statistics', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
const initialStats = sessionManager.getCacheStats()
expect(initialStats.entries).toBe(0)
// Add some cache entries
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'stats-test-1'
})
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'stats-test-2'
})
const finalStats = sessionManager.getCacheStats()
expect(finalStats.entries).toBe(2)
expect(finalStats.oldestEntry).toBeDefined()
expect(finalStats.newestEntry).toBeDefined()
})
})
describe('Error Handling', () => {
it('should identify network errors correctly', async () => {
const networkErrors = [
{ code: 'ECONNREFUSED' },
{ code: 'ETIMEDOUT' },
{ name: 'AbortError' },
{ code: 'ENOTFOUND' },
{ status: 503 }
]
for (const error of networkErrors) {
mockFetch.mockRejectedValue(error)
const result = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: `network-error-${error.code || error.name || error.status}`
})
expect(result.authenticated).toBe(false)
expect(result.reason).toBe(error.message || 'SESSION_CHECK_FAILED')
}
})
it('should handle non-network errors without grace period', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValueOnce(mockResponse)
// First successful request
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'non-network-error'
})
// Mock non-network error
const authError = new Error('Auth error')
authError.status = 401
mockFetch.mockRejectedValue(authError)
// Second request should not use cached result for auth errors
const result = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'non-network-error'
})
expect(result.authenticated).toBe(false)
expect(result.reason).toBe('Auth error')
})
})
})