diff --git a/src/app/api/performance/db-metrics/__tests__/route.test.ts b/src/app/api/performance/db-metrics/__tests__/route.test.ts new file mode 100644 index 00000000..fe6b1ad7 --- /dev/null +++ b/src/app/api/performance/db-metrics/__tests__/route.test.ts @@ -0,0 +1,131 @@ +/** + * Security Tests for DB Metrics Endpoint + * + * Tests verify authentication and authorization: + * - Anonymous requests return 401 + * - Non-admin roles return 403 + * - Admin role can access metrics + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from '../route'; +import { NextRequest } from 'next/server'; +import { UserRole } from '@/types/api'; + +describe('DB Metrics Security', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Authentication', () => { + it('should return 401 for anonymous requests (no auth header)', async () => { + const request = new NextRequest(new Request('http://localhost/api/performance/db-metrics')); + const response = await GET(request); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.message).toBe('Unauthorized'); + }); + + it('should return 401 for requests with malformed auth header', async () => { + const request = new NextRequest( + new Request('http://localhost/api/performance/db-metrics', { + headers: { authorization: 'InvalidFormat' }, + }) + ); + const response = await GET(request); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.message).toBe('Unauthorized'); + }); + + it('should return 401 for requests with Bearer token but no user-role cookie', async () => { + const request = new NextRequest( + new Request('http://localhost/api/performance/db-metrics', { + headers: { authorization: 'Bearer some-token' }, + }) + ); + const response = await GET(request); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.message).toBe('Unauthorized'); + }); + }); + + describe('Authorization', () => { + it('should return 403 for STUDENT role', async () => { + const request = new NextRequest( + new Request('http://localhost/api/performance/db-metrics', { + headers: { authorization: 'Bearer student-token' }, + }) + ); + // Mock cookie for student role + request.cookies.set('user-role', UserRole.STUDENT); + + const response = await GET(request); + + expect(response.status).toBe(403); + const data = await response.json(); + expect(data.message).toBe('Forbidden'); + }); + + it('should return 403 for INSTRUCTOR role', async () => { + const request = new NextRequest( + new Request('http://localhost/api/performance/db-metrics', { + headers: { authorization: 'Bearer instructor-token' }, + }) + ); + // Mock cookie for instructor role + request.cookies.set('user-role', UserRole.INSTRUCTOR); + + const response = await GET(request); + + expect(response.status).toBe(403); + const data = await response.json(); + expect(data.message).toBe('Forbidden'); + }); + + it('should return 403 for GUEST role', async () => { + const request = new NextRequest( + new Request('http://localhost/api/performance/db-metrics', { + headers: { authorization: 'Bearer guest-token' }, + }) + ); + // Mock cookie for guest role + request.cookies.set('user-role', UserRole.GUEST); + + const response = await GET(request); + + expect(response.status).toBe(403); + const data = await response.json(); + expect(data.message).toBe('Forbidden'); + }); + + it('should return metrics for ADMIN role', async () => { + const request = new NextRequest( + new Request('http://localhost/api/performance/db-metrics', { + headers: { authorization: 'Bearer admin-token' }, + }) + ); + // Mock cookie for admin role + request.cookies.set('user-role', UserRole.ADMIN); + + const response = await GET(request); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(data.data).toBeInstanceOf(Array); + expect(data.data.length).toBeGreaterThan(0); + + // Verify metric structure + const metricNames = data.data.map((m: any) => m.name); + expect(metricNames).toContain('db_pool_total_connections'); + expect(metricNames).toContain('db_pool_idle_connections'); + expect(metricNames).toContain('db_pool_waiting_clients'); + expect(metricNames).toContain('db_pool_active_connections'); + }); + }); +}); diff --git a/src/app/api/performance/db-metrics/route.ts b/src/app/api/performance/db-metrics/route.ts index 793dd5b0..eb5a96a6 100644 --- a/src/app/api/performance/db-metrics/route.ts +++ b/src/app/api/performance/db-metrics/route.ts @@ -1,11 +1,33 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { dbPool } from '@/lib/db/pool'; +import { requireAuth, getUserFromRequest } from '@/lib/authMiddleware'; +import { hasPermission } from '@/lib/auth/acl'; +import { Permission } from '@/types/api'; /** * API endpoint to expose database connection pool metrics * Used by the monitoring system to track resource usage. + * + * SECURITY: Requires authentication and ANALYTICS_VIEW permission (ADMIN only) */ -export async function GET() { +export async function GET(request: NextRequest) { + // T4 MITIGATION: Require authentication + const authError = requireAuth(request); + if (authError) { + return authError; + } + + // Extract user from request + const user = getUserFromRequest(request); + if (!user) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + // T4 MITIGATION: Check for ANALYTICS_VIEW permission (ADMIN only) + if (!hasPermission(user, Permission.ANALYTICS_VIEW)) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + try { const metrics = dbPool.getMetrics(); diff --git a/src/lib/authMiddleware.ts b/src/lib/authMiddleware.ts index cdce28c9..4193fce5 100644 --- a/src/lib/authMiddleware.ts +++ b/src/lib/authMiddleware.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { User, UserRole } from '@/types/api'; /** * Validates the Authorization header and returns a 401 response if missing or invalid. @@ -18,3 +19,35 @@ export function requireAuth(request: NextRequest): NextResponse | null { return null; } + +/** + * Extract user from request using Bearer token or user-role cookie. + * Returns null if user cannot be determined. + */ +export function getUserFromRequest(request: NextRequest): User | null { + // Try to get user from Bearer token (JWT would be validated in production) + const authHeader = request.headers.get('authorization'); + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7); + const roleCookie = request.cookies.get('user-role')?.value as UserRole | undefined; + if (roleCookie) { + return { + id: token, + email: '', + role: roleCookie, + }; + } + } + + // Fallback to cookie-based auth (for development/testing) + const roleCookie = request.cookies.get('user-role')?.value as UserRole | undefined; + if (roleCookie) { + return { + id: 'cookie-user', + email: '', + role: roleCookie, + }; + } + + return null; +} \ No newline at end of file