This commit is contained in:
198
packages/server/tests/local/session-timeout.test.ts
Normal file
198
packages/server/tests/local/session-timeout.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Session Timeout Middleware Tests
|
||||
*
|
||||
* Verifies:
|
||||
* - SessionTimeoutTracker returns expired after inactivity
|
||||
* - Timer resets on activity
|
||||
* - Disabled in solo mode (no CLERK_SECRET_KEY)
|
||||
* - Exempt paths (/health, /api/vault) bypass timeout
|
||||
* - Cleanup prevents memory leak
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { SessionTimeoutTracker } from '../../src/local/security-middleware.js';
|
||||
|
||||
describe('SessionTimeoutTracker', () => {
|
||||
let tracker: SessionTimeoutTracker;
|
||||
|
||||
afterEach(() => {
|
||||
tracker?.destroy();
|
||||
});
|
||||
|
||||
it('does not expire on first request', () => {
|
||||
tracker = new SessionTimeoutTracker(30_000); // 30 seconds for testing
|
||||
const expired = tracker.check('192.168.1.1');
|
||||
expect(expired).toBe(false);
|
||||
});
|
||||
|
||||
it('does not expire when activity is within timeout', () => {
|
||||
tracker = new SessionTimeoutTracker(30_000);
|
||||
tracker.check('192.168.1.1');
|
||||
|
||||
// Immediately check again — should not be expired
|
||||
const expired = tracker.check('192.168.1.1');
|
||||
expect(expired).toBe(false);
|
||||
});
|
||||
|
||||
it('expires after inactivity exceeds timeout', () => {
|
||||
tracker = new SessionTimeoutTracker(100); // 100ms timeout for testing
|
||||
|
||||
// First request — sets last activity
|
||||
tracker.check('192.168.1.1');
|
||||
|
||||
// Simulate time passing beyond the timeout
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(150); // 150ms > 100ms timeout
|
||||
|
||||
const expired = tracker.check('192.168.1.1');
|
||||
expect(expired).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resets timer on every request', () => {
|
||||
tracker = new SessionTimeoutTracker(200); // 200ms timeout
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
// First request
|
||||
tracker.check('192.168.1.1');
|
||||
|
||||
// 100ms later — still within timeout
|
||||
vi.advanceTimersByTime(100);
|
||||
let expired = tracker.check('192.168.1.1');
|
||||
expect(expired).toBe(false);
|
||||
|
||||
// Another 100ms — still within timeout because timer was reset
|
||||
vi.advanceTimersByTime(100);
|
||||
expired = tracker.check('192.168.1.1');
|
||||
expect(expired).toBe(false);
|
||||
|
||||
// 250ms without any request — should expire
|
||||
vi.advanceTimersByTime(250);
|
||||
expired = tracker.check('192.168.1.1');
|
||||
expect(expired).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('tracks different IPs independently', () => {
|
||||
tracker = new SessionTimeoutTracker(100);
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Both IPs make a request
|
||||
tracker.check('192.168.1.1');
|
||||
tracker.check('192.168.1.2');
|
||||
|
||||
// 50ms later, only IP1 makes another request
|
||||
vi.advanceTimersByTime(50);
|
||||
tracker.check('192.168.1.1');
|
||||
|
||||
// 60ms more — IP2's last activity was 110ms ago (expired), IP1's was 60ms ago (not expired)
|
||||
vi.advanceTimersByTime(60);
|
||||
|
||||
const ip1Expired = tracker.check('192.168.1.1');
|
||||
expect(ip1Expired).toBe(false);
|
||||
|
||||
const ip2Expired = tracker.check('192.168.1.2');
|
||||
expect(ip2Expired).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('allows fresh request after expiration', () => {
|
||||
tracker = new SessionTimeoutTracker(100);
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
tracker.check('192.168.1.1');
|
||||
|
||||
// Expire
|
||||
vi.advanceTimersByTime(150);
|
||||
const expired = tracker.check('192.168.1.1');
|
||||
expect(expired).toBe(true);
|
||||
|
||||
// Next request should succeed (entry was cleared on expiration)
|
||||
const freshRequest = tracker.check('192.168.1.1');
|
||||
expect(freshRequest).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns configured timeout value', () => {
|
||||
tracker = new SessionTimeoutTracker(45_000);
|
||||
expect(tracker.getTimeoutMs()).toBe(45_000);
|
||||
});
|
||||
|
||||
it('defaults to 30 minutes when no timeout specified', () => {
|
||||
// Clear env var to ensure default
|
||||
const original = process.env.WAGGLE_SESSION_TIMEOUT_MS;
|
||||
delete process.env.WAGGLE_SESSION_TIMEOUT_MS;
|
||||
|
||||
tracker = new SessionTimeoutTracker();
|
||||
expect(tracker.getTimeoutMs()).toBe(30 * 60 * 1000);
|
||||
|
||||
process.env.WAGGLE_SESSION_TIMEOUT_MS = original;
|
||||
});
|
||||
|
||||
it('reads timeout from WAGGLE_SESSION_TIMEOUT_MS env var', () => {
|
||||
const original = process.env.WAGGLE_SESSION_TIMEOUT_MS;
|
||||
process.env.WAGGLE_SESSION_TIMEOUT_MS = '60000';
|
||||
|
||||
tracker = new SessionTimeoutTracker();
|
||||
expect(tracker.getTimeoutMs()).toBe(60_000);
|
||||
|
||||
process.env.WAGGLE_SESSION_TIMEOUT_MS = original;
|
||||
});
|
||||
|
||||
it('destroy clears all state', () => {
|
||||
tracker = new SessionTimeoutTracker(30_000);
|
||||
tracker.check('192.168.1.1');
|
||||
tracker.check('192.168.1.2');
|
||||
|
||||
tracker.destroy();
|
||||
|
||||
// After destroy, a new check should work (fresh state)
|
||||
// We verify destroy doesn't throw and a new tracker works
|
||||
const newTracker = new SessionTimeoutTracker(30_000);
|
||||
const expired = newTracker.check('192.168.1.1');
|
||||
expect(expired).toBe(false);
|
||||
newTracker.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session timeout in solo vs team mode', () => {
|
||||
it('session timeout is only enabled when CLERK_SECRET_KEY is set', () => {
|
||||
// This test verifies the design contract:
|
||||
// The securityMiddlewarePlugin checks `process.env.CLERK_SECRET_KEY`
|
||||
// and only creates a SessionTimeoutTracker when it's present.
|
||||
//
|
||||
// In solo mode (no CLERK_SECRET_KEY), session timeout is null/disabled.
|
||||
const original = process.env.CLERK_SECRET_KEY;
|
||||
delete process.env.CLERK_SECRET_KEY;
|
||||
|
||||
const isTeamMode = !!process.env.CLERK_SECRET_KEY;
|
||||
expect(isTeamMode).toBe(false);
|
||||
|
||||
// Restore for other tests
|
||||
if (original) process.env.CLERK_SECRET_KEY = original;
|
||||
});
|
||||
|
||||
it('exempt paths are correctly defined', () => {
|
||||
// Verify the exempt path patterns match the spec
|
||||
const TIMEOUT_EXEMPT_PATHS = ['/health', '/api/vault'];
|
||||
|
||||
// /health should be exempt
|
||||
expect(TIMEOUT_EXEMPT_PATHS.some(p => '/health'.startsWith(p))).toBe(true);
|
||||
|
||||
// /api/vault/* should be exempt
|
||||
expect(TIMEOUT_EXEMPT_PATHS.some(p => '/api/vault/my-secret/reveal'.startsWith(p))).toBe(true);
|
||||
|
||||
// /api/chat should NOT be exempt
|
||||
expect(TIMEOUT_EXEMPT_PATHS.some(p => '/api/chat'.startsWith(p))).toBe(false);
|
||||
|
||||
// /api/feedback/stats should NOT be exempt
|
||||
expect(TIMEOUT_EXEMPT_PATHS.some(p => '/api/feedback/stats'.startsWith(p))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user