diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index fc551c1..c816cc5 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -47,6 +47,7 @@ export const createPrismaMock = () => ({ bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), goodreadsBookMapping: createModelMock(), + apiToken: createModelMock(), $queryRaw: vi.fn(), $disconnect: vi.fn(), }); diff --git a/tests/middleware/auth.middleware.test.ts b/tests/middleware/auth.middleware.test.ts index e6f9436..4f3eee2 100644 --- a/tests/middleware/auth.middleware.test.ts +++ b/tests/middleware/auth.middleware.test.ts @@ -3,9 +3,11 @@ * Documentation: documentation/backend/services/auth.md */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { NextResponse } from 'next/server'; import { createPrismaMock } from '../helpers/prisma'; +import crypto from 'crypto'; const prismaMock = createPrismaMock(); const verifyAccessTokenMock = vi.fn(); @@ -29,6 +31,11 @@ const makeRequest = (authHeader?: string) => ({ }, }); +// Helper to create a valid API token hash for testing +const createTestApiToken = (token: string) => { + return crypto.createHash('sha256').update(token).digest('hex'); +}; + describe('auth middleware', () => { beforeEach(() => { vi.clearAllMocks(); @@ -159,6 +166,98 @@ describe('auth middleware', () => { expect(result).toBe(true); }); + it('rejects JWT tokens for soft-deleted users', async () => { + verifyAccessTokenMock.mockReturnValue({ + sub: 'user-1', + plexId: 'plex-1', + username: 'user', + role: 'user', + iat: 1, + exp: 2, + }); + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: new Date(), + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/user not found/i); + }); + + describe('API token authentication', () => { + const testToken = 'rmab_test1234567890abcdef'; + const testTokenHash = createTestApiToken(testToken); + + it('rejects API tokens for soft-deleted users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: { + id: 'user-1', + plexUsername: 'deleteduser', + role: 'user', + deletedAt: new Date(), + }, + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/invalid.*expired/i); + }); + + it('rejects API tokens for missing users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: null, + }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/invalid.*expired/i); + }); + + it('accepts valid API tokens for active users', async () => { + prismaMock.apiToken.findUnique.mockResolvedValue({ + id: 'token-1', + tokenHash: testTokenHash, + role: 'user', + expiresAt: null, + tokenUser: { + id: 'user-1', + plexUsername: 'activeuser', + role: 'user', + deletedAt: null, + }, + }); + prismaMock.apiToken.update.mockResolvedValue({}); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const handler = vi.fn(async (req: any) => + NextResponse.json({ ok: true, userId: req.user?.id }) + ); + const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, handler); + const payload = await response.json(); + + expect(handler).toHaveBeenCalled(); + expect(payload.userId).toBe('user-1'); + }); + }); + it('returns current user from token', async () => { verifyAccessTokenMock.mockReturnValue({ sub: 'user-1', diff --git a/tests/utils/apiTokenRateLimit.test.ts b/tests/utils/apiTokenRateLimit.test.ts new file mode 100644 index 0000000..a80c9d8 --- /dev/null +++ b/tests/utils/apiTokenRateLimit.test.ts @@ -0,0 +1,122 @@ +/** + * Component: API Token Rate Limit Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkApiTokenCreateRateLimit, + checkApiTokenRevokeRateLimit, +} from '@/lib/utils/apiTokenRateLimit'; + +describe('API Token Rate Limiting', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + 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); + }); + }); +});