Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/app/api/performance/db-metrics/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
26 changes: 24 additions & 2 deletions src/app/api/performance/db-metrics/route.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down
33 changes: 33 additions & 0 deletions src/lib/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -18,3 +19,35 @@

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)

Check failure on line 42 in src/lib/authMiddleware.ts

View workflow job for this annotation

GitHub Actions / Type Check, Lint & Validation

Type '{ id: string; email: string; role: "ADMIN" | "INSTRUCTOR" | "STUDENT" | "GUEST"; }' is missing the following properties from type '{ id: string; name: string; email: string; role: "ADMIN" | "INSTRUCTOR" | "STUDENT" | "GUEST"; referralCount: number; referralCode?: string | undefined; referredBy?: string | undefined; }': name, referralCount
const roleCookie = request.cookies.get('user-role')?.value as UserRole | undefined;
if (roleCookie) {
return {
id: 'cookie-user',
email: '',
role: roleCookie,
};
}

return null;
}

Check failure on line 53 in src/lib/authMiddleware.ts

View workflow job for this annotation

GitHub Actions / Type Check, Lint & Validation

Type '{ id: string; email: string; role: "ADMIN" | "INSTRUCTOR" | "STUDENT" | "GUEST"; }' is missing the following properties from type '{ id: string; name: string; email: string; role: "ADMIN" | "INSTRUCTOR" | "STUDENT" | "GUEST"; referralCount: number; referralCode?: string | undefined; referredBy?: string | undefined; }': name, referralCount
Loading