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:
268
tests/auth/session-manager.test.ts
Normal file
268
tests/auth/session-manager.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user