mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
4322c3af90
Add sessions_invalidated_at to users (migration + Prisma schema) to support immediate session revocation. Set sessionsInvalidatedAt when an admin revokes a user's login token and enforce revocation checks in auth middleware and the refresh endpoint (compare token iat against sessionsInvalidatedAt). Add optional iat fields to JWT payload types. Scrub token from browser history after token-login. Consolidate rate-limiting logic into src/lib/utils/rateLimit.ts (rename/merge previous auth/apiToken rate limiter implementations), remove the old apiTokenRateLimit.ts, and update imports and tests to use the new module.
204 lines
5.9 KiB
TypeScript
204 lines
5.9 KiB
TypeScript
/**
|
|
* Component: API Token Rate Limit Tests
|
|
* Documentation: documentation/backend/services/api-tokens.md
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
checkApiTokenCreateRateLimit,
|
|
checkApiTokenRevokeRateLimit,
|
|
_resetBuckets,
|
|
_getBucketCount,
|
|
} from '@/lib/utils/rateLimit';
|
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
|
|
|
describe('API Token Rate Limiting', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
_resetBuckets();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
_resetBuckets();
|
|
});
|
|
|
|
describe('checkApiTokenCreateRateLimit', () => {
|
|
it('allows requests under the limit', () => {
|
|
const actorId = 'user-create-1';
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
const result = checkApiTokenCreateRateLimit(actorId);
|
|
expect(result.allowed).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('blocks requests over the limit (10/min)', () => {
|
|
const actorId = 'user-create-2';
|
|
|
|
// Use up the limit
|
|
for (let i = 0; i < 10; i++) {
|
|
checkApiTokenCreateRateLimit(actorId);
|
|
}
|
|
|
|
// 11th request should be blocked
|
|
const result = checkApiTokenCreateRateLimit(actorId);
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('resets after the window expires', () => {
|
|
const actorId = 'user-create-3';
|
|
|
|
// Use up the limit
|
|
for (let i = 0; i < 10; i++) {
|
|
checkApiTokenCreateRateLimit(actorId);
|
|
}
|
|
|
|
// Should be blocked
|
|
expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(false);
|
|
|
|
// Advance time past the window (60 seconds)
|
|
vi.advanceTimersByTime(61 * 1000);
|
|
|
|
// Should be allowed again
|
|
expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(true);
|
|
});
|
|
|
|
it('tracks different actors separately', () => {
|
|
const actor1 = 'user-create-4';
|
|
const actor2 = 'user-create-5';
|
|
|
|
// Use up actor1's limit
|
|
for (let i = 0; i < 10; i++) {
|
|
checkApiTokenCreateRateLimit(actor1);
|
|
}
|
|
|
|
// actor1 should be blocked
|
|
expect(checkApiTokenCreateRateLimit(actor1).allowed).toBe(false);
|
|
|
|
// actor2 should still be allowed
|
|
expect(checkApiTokenCreateRateLimit(actor2).allowed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('checkApiTokenRevokeRateLimit', () => {
|
|
it('allows requests under the limit', () => {
|
|
const actorId = 'user-revoke-1';
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
|
expect(result.allowed).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('blocks requests over the limit (20/min)', () => {
|
|
const actorId = 'user-revoke-2';
|
|
|
|
// Use up the limit
|
|
for (let i = 0; i < 20; i++) {
|
|
checkApiTokenRevokeRateLimit(actorId);
|
|
}
|
|
|
|
// 21st request should be blocked
|
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns correct retryAfterSeconds', () => {
|
|
const actorId = 'user-revoke-3';
|
|
|
|
// Use up the limit
|
|
for (let i = 0; i < 20; i++) {
|
|
checkApiTokenRevokeRateLimit(actorId);
|
|
}
|
|
|
|
// Advance 30 seconds into the window
|
|
vi.advanceTimersByTime(30 * 1000);
|
|
|
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
|
expect(result.allowed).toBe(false);
|
|
// Should have ~30 seconds left
|
|
expect(result.retryAfterSeconds).toBeLessThanOrEqual(30);
|
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('lazy eviction', () => {
|
|
it('deletes expired buckets when they are next accessed', () => {
|
|
const actorId = 'user-evict-1';
|
|
|
|
// Create a bucket
|
|
checkApiTokenCreateRateLimit(actorId);
|
|
expect(_getBucketCount()).toBe(1);
|
|
|
|
// Expire the window
|
|
vi.advanceTimersByTime(61 * 1000);
|
|
|
|
// Accessing the same key should evict the old bucket and create a fresh one
|
|
checkApiTokenCreateRateLimit(actorId);
|
|
// Should still be 1 (old one deleted, new one created)
|
|
expect(_getBucketCount()).toBe(1);
|
|
});
|
|
|
|
it('does not delete buckets that are still active', () => {
|
|
// Create buckets for two actors
|
|
checkApiTokenCreateRateLimit('actor-a');
|
|
checkApiTokenCreateRateLimit('actor-b');
|
|
expect(_getBucketCount()).toBe(2);
|
|
|
|
// Advance partially (not past the 60s window)
|
|
vi.advanceTimersByTime(30 * 1000);
|
|
|
|
// Both should still be there
|
|
checkApiTokenCreateRateLimit('actor-a');
|
|
expect(_getBucketCount()).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('periodic sweep', () => {
|
|
it('sweeps all expired buckets every 100 checks', () => {
|
|
// Create 10 unique actor buckets
|
|
for (let i = 0; i < 10; i++) {
|
|
checkApiTokenCreateRateLimit(`sweep-actor-${i}`);
|
|
}
|
|
expect(_getBucketCount()).toBe(10);
|
|
|
|
// Expire all windows
|
|
vi.advanceTimersByTime(61 * 1000);
|
|
|
|
// Add some fresh buckets that should NOT be swept
|
|
checkApiTokenCreateRateLimit('sweep-fresh-1');
|
|
checkApiTokenCreateRateLimit('sweep-fresh-2');
|
|
|
|
// We've done 10 + 2 = 12 calls so far. Need 100 total to trigger sweep.
|
|
// Do 88 more calls with unique actors to reach 100
|
|
for (let i = 0; i < 88; i++) {
|
|
checkApiTokenCreateRateLimit(`sweep-filler-${i}`);
|
|
}
|
|
|
|
// After the 100th call, the sweep should have removed the 10 expired buckets.
|
|
// Remaining: 2 fresh + 88 filler = 90
|
|
expect(_getBucketCount()).toBe(90);
|
|
});
|
|
});
|
|
|
|
describe('_resetBuckets', () => {
|
|
it('clears all buckets', () => {
|
|
checkApiTokenCreateRateLimit('reset-1');
|
|
checkApiTokenCreateRateLimit('reset-2');
|
|
expect(_getBucketCount()).toBeGreaterThan(0);
|
|
|
|
_resetBuckets();
|
|
expect(_getBucketCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('MAX_TOKENS_PER_USER constant', () => {
|
|
it('is set to 25', () => {
|
|
expect(MAX_TOKENS_PER_USER).toBe(25);
|
|
});
|
|
});
|
|
});
|