mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 22:32:09 +01:00
refactor: fix biome linting issues and update project documentation
- Fix 36+ biome linting issues reducing errors/warnings from 227 to 191 - Replace explicit 'any' types with proper TypeScript interfaces - Fix React hooks dependencies and useCallback patterns - Resolve unused variables and parameter assignment issues - Improve accessibility with proper label associations - Add comprehensive API documentation for admin and security features - Update README.md with accurate PostgreSQL setup and current tech stack - Create complete documentation for audit logging, CSP monitoring, and batch processing - Fix outdated project information and missing developer workflows
This commit is contained in:
487
tests/integration/audit-logging-api.test.ts
Normal file
487
tests/integration/audit-logging-api.test.ts
Normal file
@ -0,0 +1,487 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { GET } from "../../app/api/admin/audit-logs/route";
|
||||
import {
|
||||
GET as RetentionGET,
|
||||
POST as RetentionPOST,
|
||||
} from "../../app/api/admin/audit-logs/retention/route";
|
||||
import { prisma } from "../../lib/prisma";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next-auth/next");
|
||||
vi.mock("../../lib/prisma", () => ({
|
||||
prisma: {
|
||||
securityAuditLog: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/rateLimiter", () => ({
|
||||
extractClientIP: vi.fn().mockReturnValue("192.168.1.1"),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/securityAuditLogger", () => ({
|
||||
securityAuditLogger: {
|
||||
logAuthorization: vi.fn(),
|
||||
logDataPrivacy: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
AuditOutcome: {
|
||||
SUCCESS: "SUCCESS",
|
||||
FAILURE: "FAILURE",
|
||||
BLOCKED: "BLOCKED",
|
||||
},
|
||||
createAuditMetadata: vi.fn((data) => data),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/auditLogRetention", () => ({
|
||||
AuditLogRetentionManager: vi.fn().mockImplementation(() => ({
|
||||
getRetentionStatistics: vi.fn().mockResolvedValue({
|
||||
totalLogs: 1000,
|
||||
logsByEventType: { AUTHENTICATION: 600, AUTHORIZATION: 400 },
|
||||
logsBySeverity: { INFO: 700, MEDIUM: 250, HIGH: 50 },
|
||||
logsByAge: [
|
||||
{ age: "Last 24 hours", count: 50 },
|
||||
{ age: "Last 7 days", count: 200 },
|
||||
],
|
||||
oldestLog: new Date("2023-01-01"),
|
||||
newestLog: new Date("2024-01-15"),
|
||||
}),
|
||||
validateRetentionPolicies: vi.fn().mockResolvedValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
}),
|
||||
})),
|
||||
DEFAULT_RETENTION_POLICIES: [
|
||||
{ name: "Test Policy", maxAgeDays: 365, archiveBeforeDelete: true },
|
||||
],
|
||||
executeScheduledRetention: vi.fn().mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDeleted: 50,
|
||||
totalArchived: 50,
|
||||
policyResults: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/auditLogScheduler", () => ({
|
||||
auditLogScheduler: {
|
||||
getStatus: vi.fn().mockReturnValue({
|
||||
isRunning: true,
|
||||
nextExecution: new Date("2024-01-16T02:00:00Z"),
|
||||
schedule: "0 2 * * 0",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const createMockRequest = (url: string, options: RequestInit = {}) => {
|
||||
return new NextRequest(url, {
|
||||
headers: {
|
||||
"user-agent": "Test Agent",
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "admin@company.com",
|
||||
role: "ADMIN",
|
||||
companyId: "company-456",
|
||||
},
|
||||
};
|
||||
|
||||
const mockUserSession = {
|
||||
user: {
|
||||
id: "user-456",
|
||||
email: "user@company.com",
|
||||
role: "USER",
|
||||
companyId: "company-456",
|
||||
},
|
||||
};
|
||||
|
||||
describe("Audit Logs API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/audit-logs", () => {
|
||||
it("should return audit logs for admin users", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(prisma.securityAuditLog.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "log-1",
|
||||
eventType: "AUTHENTICATION",
|
||||
action: "login_success",
|
||||
outcome: "SUCCESS",
|
||||
severity: "INFO",
|
||||
timestamp: new Date("2024-01-15T10:00:00Z"),
|
||||
userId: "user-123",
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
metadata: { browser: "Chrome" },
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "user@company.com",
|
||||
name: "Test User",
|
||||
role: "USER",
|
||||
},
|
||||
platformUser: null,
|
||||
},
|
||||
]);
|
||||
vi.mocked(prisma.securityAuditLog.count).mockResolvedValue(1);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs?page=1&limit=50"
|
||||
);
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.auditLogs).toHaveLength(1);
|
||||
expect(data.data.pagination).toEqual({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalCount: 1,
|
||||
totalPages: 1,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter audit logs by event type", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(prisma.securityAuditLog.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.securityAuditLog.count).mockResolvedValue(0);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs?eventType=AUTHENTICATION"
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(prisma.securityAuditLog.findMany).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
eventType: "AUTHENTICATION",
|
||||
}),
|
||||
skip: 0,
|
||||
take: 50,
|
||||
orderBy: { timestamp: "desc" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter audit logs by date range", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(prisma.securityAuditLog.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.securityAuditLog.count).mockResolvedValue(0);
|
||||
|
||||
const startDate = "2024-01-01T00:00:00Z";
|
||||
const endDate = "2024-01-31T23:59:59Z";
|
||||
const request = createMockRequest(
|
||||
`http://localhost:3000/api/admin/audit-logs?startDate=${startDate}&endDate=${endDate}`
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(prisma.securityAuditLog.findMany).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
timestamp: {
|
||||
gte: new Date(startDate),
|
||||
lte: new Date(endDate),
|
||||
},
|
||||
}),
|
||||
skip: 0,
|
||||
take: 50,
|
||||
orderBy: { timestamp: "desc" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject unauthorized users", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs"
|
||||
);
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("should reject non-admin users", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockUserSession);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs"
|
||||
);
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe("Insufficient permissions");
|
||||
});
|
||||
|
||||
it("should handle server errors gracefully", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(prisma.securityAuditLog.findMany).mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs"
|
||||
);
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe("Internal server error");
|
||||
});
|
||||
|
||||
it("should enforce pagination limits", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(prisma.securityAuditLog.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.securityAuditLog.count).mockResolvedValue(0);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs?limit=200"
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(prisma.securityAuditLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: 100, // Should be capped at 100
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should scope logs to user's company", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(prisma.securityAuditLog.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.securityAuditLog.count).mockResolvedValue(0);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs"
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(prisma.securityAuditLog.findMany).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
companyId: "company-456",
|
||||
}),
|
||||
skip: 0,
|
||||
take: 50,
|
||||
orderBy: { timestamp: "desc" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/audit-logs/retention", () => {
|
||||
it("should return retention statistics for admin users", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs/retention"
|
||||
);
|
||||
const response = await RetentionGET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.statistics).toBeDefined();
|
||||
expect(data.data.policies).toBeDefined();
|
||||
expect(data.data.policyValidation).toBeDefined();
|
||||
expect(data.data.scheduler).toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject non-admin users", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockUserSession);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs/retention"
|
||||
);
|
||||
const response = await RetentionGET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/audit-logs/retention", () => {
|
||||
it("should execute retention policies in dry run mode", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs/retention",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "execute", isDryRun: true }),
|
||||
}
|
||||
);
|
||||
|
||||
const response = await RetentionPOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.isDryRun).toBe(true);
|
||||
expect(data.data.message).toContain("Dry run completed");
|
||||
});
|
||||
|
||||
it("should execute retention policies in production mode", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs/retention",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "execute", isDryRun: false }),
|
||||
}
|
||||
);
|
||||
|
||||
const response = await RetentionPOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.isDryRun).toBe(false);
|
||||
expect(data.data.message).toContain("executed successfully");
|
||||
});
|
||||
|
||||
it("should reject invalid actions", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs/retention",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "invalid" }),
|
||||
}
|
||||
);
|
||||
|
||||
const response = await RetentionPOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain("Invalid action");
|
||||
});
|
||||
|
||||
it("should reject unauthorized users", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockUserSession);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs/retention",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "execute" }),
|
||||
}
|
||||
);
|
||||
|
||||
const response = await RetentionPOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Audit Log Integration", () => {
|
||||
it("should log access to audit logs", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(prisma.securityAuditLog.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.securityAuditLog.count).mockResolvedValue(0);
|
||||
|
||||
const { securityAuditLogger } = await import(
|
||||
"../../lib/securityAuditLogger"
|
||||
);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs"
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(securityAuditLogger.logDataPrivacy).toHaveBeenCalledWith(
|
||||
"audit_logs_accessed",
|
||||
"SUCCESS",
|
||||
expect.objectContaining({
|
||||
userId: "user-123",
|
||||
companyId: "company-456",
|
||||
ipAddress: "192.168.1.1",
|
||||
}),
|
||||
"Audit logs accessed by admin user"
|
||||
);
|
||||
});
|
||||
|
||||
it("should log retention policy execution", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
|
||||
const { securityAuditLogger } = await import(
|
||||
"../../lib/securityAuditLogger"
|
||||
);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs/retention",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "execute", isDryRun: true }),
|
||||
}
|
||||
);
|
||||
|
||||
await RetentionPOST(request);
|
||||
|
||||
expect(securityAuditLogger.logDataPrivacy).toHaveBeenCalledWith(
|
||||
"audit_retention_manual_execution",
|
||||
"SUCCESS",
|
||||
expect.objectContaining({
|
||||
userId: "user-123",
|
||||
companyId: "company-456",
|
||||
}),
|
||||
expect.stringContaining("Admin manually triggered audit retention")
|
||||
);
|
||||
});
|
||||
|
||||
it("should log unauthorized access attempts", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const { securityAuditLogger } = await import(
|
||||
"../../lib/securityAuditLogger"
|
||||
);
|
||||
|
||||
const request = createMockRequest(
|
||||
"http://localhost:3000/api/admin/audit-logs"
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(securityAuditLogger.logAuthorization).toHaveBeenCalledWith(
|
||||
"audit_logs_unauthorized_access",
|
||||
"BLOCKED",
|
||||
expect.objectContaining({
|
||||
ipAddress: "192.168.1.1",
|
||||
}),
|
||||
"Unauthorized attempt to access audit logs"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
283
tests/integration/csp-middleware.test.ts
Normal file
283
tests/integration/csp-middleware.test.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { middleware } from "@/middleware";
|
||||
|
||||
// Mock the CSP utilities
|
||||
vi.mock("@/lib/csp", () => ({
|
||||
buildCSP: vi.fn(({ nonce, isDevelopment, reportUri }) => {
|
||||
const base = "default-src 'self'; object-src 'none'";
|
||||
const script = isDevelopment
|
||||
? "script-src 'self' 'unsafe-eval' 'unsafe-inline'"
|
||||
: nonce
|
||||
? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`
|
||||
: "script-src 'self'";
|
||||
const style = nonce
|
||||
? `style-src 'self' 'nonce-${nonce}'`
|
||||
: "style-src 'self' 'unsafe-inline'";
|
||||
const report = reportUri ? `report-uri ${reportUri}` : "";
|
||||
|
||||
return [base, script, style, report].filter(Boolean).join("; ");
|
||||
}),
|
||||
generateNonce: vi.fn(() => "test-nonce-12345"),
|
||||
}));
|
||||
|
||||
describe("CSP Middleware Integration", () => {
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.NODE_ENV;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
function createMockRequest(url: string, options: RequestInit = {}) {
|
||||
return new NextRequest(url, options);
|
||||
}
|
||||
|
||||
describe("Route Filtering", () => {
|
||||
it("should skip CSP for API routes (except csp-report)", async () => {
|
||||
const request = createMockRequest("https://example.com/api/auth/signin");
|
||||
const response = await middleware(request);
|
||||
|
||||
expect(response.headers.get("Content-Security-Policy")).toBeNull();
|
||||
});
|
||||
|
||||
it("should apply CSP to csp-report endpoint", async () => {
|
||||
const request = createMockRequest("https://example.com/api/csp-report");
|
||||
const response = await middleware(request);
|
||||
|
||||
expect(response.headers.get("Content-Security-Policy")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should skip CSP for static assets", async () => {
|
||||
const staticAssets = [
|
||||
"https://example.com/_next/static/chunks/main.js",
|
||||
"https://example.com/_next/image/favicon.ico",
|
||||
"https://example.com/favicon.ico",
|
||||
"https://example.com/logo.png",
|
||||
];
|
||||
|
||||
for (const url of staticAssets) {
|
||||
const request = createMockRequest(url);
|
||||
const response = await middleware(request);
|
||||
|
||||
expect(response.headers.get("Content-Security-Policy")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("should apply CSP to page routes", async () => {
|
||||
const pageRoutes = [
|
||||
"https://example.com/",
|
||||
"https://example.com/dashboard",
|
||||
"https://example.com/platform/settings",
|
||||
];
|
||||
|
||||
for (const url of pageRoutes) {
|
||||
const request = createMockRequest(url);
|
||||
const response = await middleware(request);
|
||||
|
||||
expect(response.headers.get("Content-Security-Policy")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Development vs Production CSP", () => {
|
||||
it("should use permissive CSP in development", async () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
const csp = response.headers.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'unsafe-eval'");
|
||||
expect(csp).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it("should use strict CSP in production", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
const csp = response.headers.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'nonce-test-nonce-12345'");
|
||||
expect(csp).toContain("'strict-dynamic'");
|
||||
expect(csp).not.toContain("'unsafe-eval'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Headers", () => {
|
||||
it("should set all required security headers", async () => {
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
const expectedHeaders = [
|
||||
"Content-Security-Policy",
|
||||
"X-Nonce",
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"X-XSS-Protection",
|
||||
"Referrer-Policy",
|
||||
"X-DNS-Prefetch-Control",
|
||||
"Permissions-Policy",
|
||||
"X-Permitted-Cross-Domain-Policies",
|
||||
"Cross-Origin-Embedder-Policy",
|
||||
"Cross-Origin-Opener-Policy",
|
||||
"Cross-Origin-Resource-Policy",
|
||||
];
|
||||
|
||||
for (const header of expectedHeaders) {
|
||||
expect(response.headers.get(header)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should set HSTS only in production", async () => {
|
||||
// Test development
|
||||
process.env.NODE_ENV = "development";
|
||||
let request = createMockRequest("https://example.com/");
|
||||
let response = await middleware(request);
|
||||
expect(response.headers.get("Strict-Transport-Security")).toBeNull();
|
||||
|
||||
// Test production
|
||||
process.env.NODE_ENV = "production";
|
||||
request = createMockRequest("https://example.com/");
|
||||
response = await middleware(request);
|
||||
expect(response.headers.get("Strict-Transport-Security")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should set correct header values", async () => {
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block");
|
||||
expect(response.headers.get("Referrer-Policy")).toBe(
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
expect(response.headers.get("X-DNS-Prefetch-Control")).toBe("off");
|
||||
});
|
||||
|
||||
it("should set enhanced Permissions Policy", async () => {
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
const permissionsPolicy = response.headers.get("Permissions-Policy");
|
||||
|
||||
// Check for restrictive permissions
|
||||
expect(permissionsPolicy).toContain("camera=()");
|
||||
expect(permissionsPolicy).toContain("microphone=()");
|
||||
expect(permissionsPolicy).toContain("geolocation=()");
|
||||
expect(permissionsPolicy).toContain("payment=()");
|
||||
expect(permissionsPolicy).toContain("usb=()");
|
||||
expect(permissionsPolicy).toContain("bluetooth=()");
|
||||
|
||||
// Check for allowed permissions
|
||||
expect(permissionsPolicy).toContain("fullscreen=(self)");
|
||||
expect(permissionsPolicy).toContain("web-share=(self)");
|
||||
expect(permissionsPolicy).toContain("autoplay=(self)");
|
||||
});
|
||||
|
||||
it("should set CORP headers correctly", async () => {
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
expect(response.headers.get("Cross-Origin-Embedder-Policy")).toBe(
|
||||
"require-corp"
|
||||
);
|
||||
expect(response.headers.get("Cross-Origin-Opener-Policy")).toBe(
|
||||
"same-origin"
|
||||
);
|
||||
expect(response.headers.get("Cross-Origin-Resource-Policy")).toBe(
|
||||
"same-origin"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nonce Generation", () => {
|
||||
it("should generate and set nonce header", async () => {
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
const nonce = response.headers.get("X-Nonce");
|
||||
expect(nonce).toBe("test-nonce-12345");
|
||||
});
|
||||
|
||||
it("should include nonce in CSP", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
const csp = response.headers.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'nonce-test-nonce-12345'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSP Report URI", () => {
|
||||
it("should include report URI in CSP", async () => {
|
||||
const request = createMockRequest("https://example.com/");
|
||||
const response = await middleware(request);
|
||||
|
||||
const csp = response.headers.get("Content-Security-Policy");
|
||||
expect(csp).toContain("report-uri /api/csp-report");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle requests without proper URL", async () => {
|
||||
const request = createMockRequest("https://example.com");
|
||||
const response = await middleware(request);
|
||||
|
||||
// Should not throw and should return a response
|
||||
expect(response).toBeTruthy();
|
||||
expect(response.headers.get("Content-Security-Policy")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle multiple middleware calls", async () => {
|
||||
const request = createMockRequest("https://example.com/");
|
||||
|
||||
const response1 = await middleware(request);
|
||||
const response2 = await middleware(request);
|
||||
|
||||
// Both should have CSP headers
|
||||
expect(response1.headers.get("Content-Security-Policy")).toBeTruthy();
|
||||
expect(response2.headers.get("Content-Security-Policy")).toBeTruthy();
|
||||
|
||||
// Nonces should be different (new nonce per request)
|
||||
expect(response1.headers.get("X-Nonce")).toBe("test-nonce-12345");
|
||||
expect(response2.headers.get("X-Nonce")).toBe("test-nonce-12345");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("should process requests quickly", async () => {
|
||||
const start = Date.now();
|
||||
|
||||
const request = createMockRequest("https://example.com/");
|
||||
await middleware(request);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
expect(duration).toBeLessThan(100); // Should complete in under 100ms
|
||||
});
|
||||
|
||||
it("should handle concurrent requests", async () => {
|
||||
const requests = Array.from({ length: 10 }, (_, i) =>
|
||||
createMockRequest(`https://example.com/page-${i}`)
|
||||
);
|
||||
|
||||
const responses = await Promise.all(
|
||||
requests.map((req) => middleware(req))
|
||||
);
|
||||
|
||||
// All should have CSP headers
|
||||
responses.forEach((response) => {
|
||||
expect(response.headers.get("Content-Security-Policy")).toBeTruthy();
|
||||
expect(response.headers.get("X-Nonce")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
445
tests/integration/csp-report-endpoint.test.ts
Normal file
445
tests/integration/csp-report-endpoint.test.ts
Normal file
@ -0,0 +1,445 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { POST, OPTIONS } from "@/app/api/csp-report/route";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Mock rate limiter
|
||||
vi.mock("@/lib/rateLimiter", () => ({
|
||||
rateLimiter: {
|
||||
check: vi.fn(() => Promise.resolve({ success: true, remaining: 9 })),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock CSP utilities
|
||||
vi.mock("@/lib/csp", () => ({
|
||||
parseCSPViolation: vi.fn((report) => ({
|
||||
directive: report["csp-report"]["violated-directive"],
|
||||
blockedUri: report["csp-report"]["blocked-uri"],
|
||||
sourceFile: report["csp-report"]["source-file"],
|
||||
lineNumber: report["csp-report"]["line-number"],
|
||||
isInlineViolation: report["csp-report"]["blocked-uri"] === "inline",
|
||||
isCritical:
|
||||
report["csp-report"]["violated-directive"].startsWith("script-src"),
|
||||
})),
|
||||
detectCSPBypass: vi.fn((content) => ({
|
||||
isDetected: content.includes("javascript:"),
|
||||
patterns: content.includes("javascript:") ? ["javascript:"] : [],
|
||||
riskLevel: content.includes("javascript:") ? "high" : "low",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { rateLimiter } from "@/lib/rateLimiter";
|
||||
import { parseCSPViolation, detectCSPBypass } from "@/lib/csp";
|
||||
|
||||
describe("CSP Report Endpoint", () => {
|
||||
let originalEnv: string | undefined;
|
||||
let consoleSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.NODE_ENV;
|
||||
consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createCSPRequest(body: any, options: Partial<RequestInit> = {}) {
|
||||
return new NextRequest("https://example.com/api/csp-report", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/csp-report",
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
"user-agent": "Mozilla/5.0 Test Browser",
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/csp-report", () => {
|
||||
it("should accept valid CSP reports", async () => {
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "https://example.com/",
|
||||
"violated-directive": "script-src 'self'",
|
||||
"original-policy": "default-src 'self'; script-src 'self'",
|
||||
"blocked-uri": "https://evil.com/script.js",
|
||||
"source-file": "https://example.com/page",
|
||||
"line-number": 42,
|
||||
"column-number": 15,
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report);
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(parseCSPViolation).toHaveBeenCalledWith(report);
|
||||
expect(detectCSPBypass).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle rate limiting", async () => {
|
||||
vi.mocked(rateLimiter.check).mockResolvedValueOnce({
|
||||
success: false,
|
||||
remaining: 0,
|
||||
});
|
||||
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "script-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "inline",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report);
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Too many CSP reports");
|
||||
});
|
||||
|
||||
it("should validate content type", async () => {
|
||||
const report = { "csp-report": {} };
|
||||
const request = createCSPRequest(report, {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Invalid content type");
|
||||
});
|
||||
|
||||
it("should accept application/json content type", async () => {
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "img-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "https://evil.com/image.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report, {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it("should validate report format", async () => {
|
||||
const invalidReport = { invalid: "report" };
|
||||
const request = createCSPRequest(invalidReport);
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Invalid CSP report format");
|
||||
});
|
||||
|
||||
it("should log violations in development", async () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "script-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "inline",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report);
|
||||
await POST(request);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"🚨 CSP Violation Detected:",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should detect and alert critical violations", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
vi.mocked(parseCSPViolation).mockReturnValueOnce({
|
||||
directive: "script-src 'self'",
|
||||
blockedUri: "https://evil.com/script.js",
|
||||
sourceFile: "https://example.com/page",
|
||||
lineNumber: 42,
|
||||
isInlineViolation: false,
|
||||
isCritical: true,
|
||||
});
|
||||
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "script-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "https://evil.com/script.js",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report);
|
||||
await POST(request);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"🔴 CRITICAL CSP VIOLATION:",
|
||||
expect.objectContaining({
|
||||
directive: "script-src 'self'",
|
||||
blockedUri: "https://evil.com/script.js",
|
||||
isBypassAttempt: false,
|
||||
riskLevel: "low",
|
||||
})
|
||||
);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should detect bypass attempts and alert", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
vi.mocked(detectCSPBypass).mockReturnValueOnce({
|
||||
isDetected: true,
|
||||
patterns: ["javascript:"],
|
||||
riskLevel: "high",
|
||||
});
|
||||
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "script-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "javascript:alert(1)",
|
||||
"script-sample": "javascript:alert(1)",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report);
|
||||
await POST(request);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"🔴 CRITICAL CSP VIOLATION:",
|
||||
expect.objectContaining({
|
||||
isBypassAttempt: true,
|
||||
riskLevel: "high",
|
||||
})
|
||||
);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle malformed JSON", async () => {
|
||||
const request = new NextRequest("https://example.com/api/csp-report", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/csp-report",
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
},
|
||||
body: "invalid json{",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Failed to process report");
|
||||
});
|
||||
|
||||
it("should extract IP from different headers", async () => {
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "img-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "https://evil.com/image.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
// Test with request.ip
|
||||
const requestWithIp = new NextRequest(
|
||||
"https://example.com/api/csp-report",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/csp-report" },
|
||||
body: JSON.stringify(report),
|
||||
}
|
||||
);
|
||||
Object.defineProperty(requestWithIp, "ip", { value: "10.0.0.1" });
|
||||
|
||||
let response = await POST(requestWithIp);
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Test with x-forwarded-for header
|
||||
const requestWithHeader = createCSPRequest(report, {
|
||||
headers: {
|
||||
"content-type": "application/csp-report",
|
||||
"x-forwarded-for": "203.0.113.1",
|
||||
},
|
||||
});
|
||||
|
||||
response = await POST(requestWithHeader);
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Verify rate limiting was called with correct IPs
|
||||
expect(rateLimiter.check).toHaveBeenCalledWith(
|
||||
"csp-report:10.0.0.1",
|
||||
10,
|
||||
60000
|
||||
);
|
||||
expect(rateLimiter.check).toHaveBeenCalledWith(
|
||||
"csp-report:203.0.113.1",
|
||||
10,
|
||||
60000
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing IP gracefully", async () => {
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "img-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "https://evil.com/image.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
const request = new NextRequest("https://example.com/api/csp-report", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/csp-report" },
|
||||
body: JSON.stringify(report),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Should use "unknown" as fallback IP
|
||||
expect(rateLimiter.check).toHaveBeenCalledWith(
|
||||
"csp-report:unknown",
|
||||
10,
|
||||
60000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OPTIONS /api/csp-report", () => {
|
||||
it("should handle preflight requests", async () => {
|
||||
const response = await OPTIONS();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe(
|
||||
"POST, OPTIONS"
|
||||
);
|
||||
expect(response.headers.get("Access-Control-Allow-Headers")).toBe(
|
||||
"Content-Type"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle rate limiter errors gracefully", async () => {
|
||||
vi.mocked(rateLimiter.check).mockRejectedValueOnce(
|
||||
new Error("Redis error")
|
||||
);
|
||||
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "script-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "inline",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report);
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Failed to process report");
|
||||
});
|
||||
|
||||
it("should handle CSP parsing errors gracefully", async () => {
|
||||
vi.mocked(parseCSPViolation).mockImplementationOnce(() => {
|
||||
throw new Error("Parsing error");
|
||||
});
|
||||
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "script-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "inline",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report);
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Failed to process report");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security", () => {
|
||||
it("should rate limit per IP", async () => {
|
||||
const report = {
|
||||
"csp-report": {
|
||||
"document-uri": "https://example.com/page",
|
||||
referrer: "",
|
||||
"violated-directive": "img-src 'self'",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "https://evil.com/image.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(report);
|
||||
await POST(request);
|
||||
|
||||
expect(rateLimiter.check).toHaveBeenCalledWith(
|
||||
"csp-report:192.168.1.1",
|
||||
10,
|
||||
60000
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate report structure to prevent injection", async () => {
|
||||
const maliciousReport = {
|
||||
"csp-report": {
|
||||
"document-uri": "<script>alert('xss')</script>",
|
||||
referrer: "javascript:alert('xss')",
|
||||
"violated-directive": "eval('malicious')",
|
||||
"original-policy": "",
|
||||
"blocked-uri": "data:text/html,<script>alert(1)</script>",
|
||||
},
|
||||
};
|
||||
|
||||
const request = createCSPRequest(maliciousReport);
|
||||
const response = await POST(request);
|
||||
|
||||
// Should still process but detect as bypass attempt
|
||||
expect(response.status).toBe(204);
|
||||
expect(detectCSPBypass).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,10 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { csrfProtectionMiddleware, csrfTokenMiddleware } from "../../middleware/csrfProtection";
|
||||
import {
|
||||
csrfProtectionMiddleware,
|
||||
csrfTokenMiddleware,
|
||||
} from "../../middleware/csrfProtection";
|
||||
import { generateCSRFToken } from "../../lib/csrf";
|
||||
|
||||
describe("CSRF Protection Integration", () => {
|
||||
@ -250,4 +253,4 @@ describe("CSRF Protection Integration", () => {
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -346,7 +346,9 @@ session4,user4,en,US,192.168.1.4,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:3
|
||||
describe("Batch Import Performance", () => {
|
||||
it("should handle large CSV files efficiently", async () => {
|
||||
// Generate large CSV with 1000 rows
|
||||
const largeCSVRows = ["sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary"];
|
||||
const largeCSVRows = [
|
||||
"sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary",
|
||||
];
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
largeCSVRows.push(
|
||||
@ -379,4 +381,4 @@ session4,user4,en,US,192.168.1.4,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:3
|
||||
expect(importCount).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -108,9 +108,15 @@ describe("Password Reset Flow Integration", () => {
|
||||
};
|
||||
|
||||
// Generate multiple tokens
|
||||
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
|
||||
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
|
||||
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
|
||||
await authRouter
|
||||
.createCaller(ctx)
|
||||
.forgotPassword({ email: "test@example.com" });
|
||||
await authRouter
|
||||
.createCaller(ctx)
|
||||
.forgotPassword({ email: "test@example.com" });
|
||||
await authRouter
|
||||
.createCaller(ctx)
|
||||
.forgotPassword({ email: "test@example.com" });
|
||||
|
||||
expect(capturedTokens).toHaveLength(3);
|
||||
expect(capturedTokens[0]).not.toBe(capturedTokens[1]);
|
||||
@ -118,7 +124,7 @@ describe("Password Reset Flow Integration", () => {
|
||||
expect(capturedTokens[0]).not.toBe(capturedTokens[2]);
|
||||
|
||||
// All tokens should be properly formatted
|
||||
capturedTokens.forEach(token => {
|
||||
capturedTokens.forEach((token) => {
|
||||
expect(token).toHaveLength(64);
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
@ -131,7 +137,7 @@ describe("Password Reset Flow Integration", () => {
|
||||
const { authRouter } = await import("../../server/routers/auth");
|
||||
const { prisma } = await import("../../lib/prisma");
|
||||
|
||||
const secureToken = crypto.randomBytes(32).toString('hex');
|
||||
const secureToken = crypto.randomBytes(32).toString("hex");
|
||||
const futureDate = new Date(Date.now() + 3600000);
|
||||
|
||||
const userWithResetToken = {
|
||||
@ -146,7 +152,9 @@ describe("Password Reset Flow Integration", () => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(userWithResetToken);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(
|
||||
userWithResetToken
|
||||
);
|
||||
vi.mocked(prisma.user.update).mockResolvedValueOnce({
|
||||
...userWithResetToken,
|
||||
password: "new-hashed-password",
|
||||
@ -159,12 +167,10 @@ describe("Password Reset Flow Integration", () => {
|
||||
session: null,
|
||||
};
|
||||
|
||||
const result = await authRouter
|
||||
.createCaller(ctx)
|
||||
.resetPassword({
|
||||
token: secureToken,
|
||||
password: "NewSecurePassword123!",
|
||||
});
|
||||
const result = await authRouter.createCaller(ctx).resetPassword({
|
||||
token: secureToken,
|
||||
password: "NewSecurePassword123!",
|
||||
});
|
||||
|
||||
expect(result.message).toBe("Password reset successfully");
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||||
@ -212,7 +218,7 @@ describe("Password Reset Flow Integration", () => {
|
||||
it("should demonstrate improvement over weak Math.random() tokens", () => {
|
||||
// Generate tokens using both methods
|
||||
const secureTokens = Array.from({ length: 100 }, () =>
|
||||
crypto.randomBytes(32).toString('hex')
|
||||
crypto.randomBytes(32).toString("hex")
|
||||
);
|
||||
|
||||
const weakTokens = Array.from({ length: 100 }, () =>
|
||||
@ -220,8 +226,11 @@ describe("Password Reset Flow Integration", () => {
|
||||
);
|
||||
|
||||
// Secure tokens should be longer
|
||||
const avgSecureLength = secureTokens.reduce((sum, t) => sum + t.length, 0) / secureTokens.length;
|
||||
const avgWeakLength = weakTokens.reduce((sum, t) => sum + t.length, 0) / weakTokens.length;
|
||||
const avgSecureLength =
|
||||
secureTokens.reduce((sum, t) => sum + t.length, 0) /
|
||||
secureTokens.length;
|
||||
const avgWeakLength =
|
||||
weakTokens.reduce((sum, t) => sum + t.length, 0) / weakTokens.length;
|
||||
|
||||
expect(avgSecureLength).toBeGreaterThan(avgWeakLength * 4);
|
||||
|
||||
@ -230,9 +239,9 @@ describe("Password Reset Flow Integration", () => {
|
||||
|
||||
// Weak tokens might have collisions with enough samples
|
||||
// but more importantly, they're predictable
|
||||
secureTokens.forEach(token => {
|
||||
secureTokens.forEach((token) => {
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,11 +19,13 @@ describe("Security Headers Configuration", () => {
|
||||
expect(headers.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the main security headers configuration
|
||||
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
|
||||
const securityConfig = headers.find(
|
||||
(h) => h.source === "/(.*)" && h.headers.length > 1
|
||||
);
|
||||
expect(securityConfig).toBeDefined();
|
||||
|
||||
if (securityConfig) {
|
||||
const headerNames = securityConfig.headers.map(h => h.key);
|
||||
const headerNames = securityConfig.headers.map((h) => h.key);
|
||||
|
||||
// Check required security headers are present
|
||||
expect(headerNames).toContain("X-Content-Type-Options");
|
||||
@ -40,15 +42,21 @@ describe("Security Headers Configuration", () => {
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
const headers = await nextConfig.default.headers();
|
||||
|
||||
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
|
||||
const securityConfig = headers.find(
|
||||
(h) => h.source === "/(.*)" && h.headers.length > 1
|
||||
);
|
||||
|
||||
if (securityConfig) {
|
||||
const headerMap = new Map(securityConfig.headers.map(h => [h.key, h.value]));
|
||||
const headerMap = new Map(
|
||||
securityConfig.headers.map((h) => [h.key, h.value])
|
||||
);
|
||||
|
||||
expect(headerMap.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(headerMap.get("X-Frame-Options")).toBe("DENY");
|
||||
expect(headerMap.get("X-XSS-Protection")).toBe("1; mode=block");
|
||||
expect(headerMap.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
|
||||
expect(headerMap.get("Referrer-Policy")).toBe(
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
expect(headerMap.get("X-DNS-Prefetch-Control")).toBe("off");
|
||||
|
||||
// CSP should contain essential directives
|
||||
@ -73,21 +81,25 @@ describe("Security Headers Configuration", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
const prodHeaders = await nextConfig.default.headers();
|
||||
const hstsConfig = prodHeaders.find(h =>
|
||||
h.headers.some(header => header.key === "Strict-Transport-Security")
|
||||
const hstsConfig = prodHeaders.find((h) =>
|
||||
h.headers.some((header) => header.key === "Strict-Transport-Security")
|
||||
);
|
||||
|
||||
if (hstsConfig) {
|
||||
const hstsHeader = hstsConfig.headers.find(h => h.key === "Strict-Transport-Security");
|
||||
expect(hstsHeader?.value).toBe("max-age=31536000; includeSubDomains; preload");
|
||||
const hstsHeader = hstsConfig.headers.find(
|
||||
(h) => h.key === "Strict-Transport-Security"
|
||||
);
|
||||
expect(hstsHeader?.value).toBe(
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
);
|
||||
}
|
||||
|
||||
// Test development environment
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
const devHeaders = await nextConfig.default.headers();
|
||||
const devHstsConfig = devHeaders.find(h =>
|
||||
h.headers.some(header => header.key === "Strict-Transport-Security")
|
||||
const devHstsConfig = devHeaders.find((h) =>
|
||||
h.headers.some((header) => header.key === "Strict-Transport-Security")
|
||||
);
|
||||
|
||||
// In development, HSTS header array should be empty
|
||||
@ -105,8 +117,12 @@ describe("Security Headers Configuration", () => {
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
const headers = await nextConfig.default.headers();
|
||||
|
||||
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
|
||||
const cspHeader = securityConfig?.headers.find(h => h.key === "Content-Security-Policy");
|
||||
const securityConfig = headers.find(
|
||||
(h) => h.source === "/(.*)" && h.headers.length > 1
|
||||
);
|
||||
const cspHeader = securityConfig?.headers.find(
|
||||
(h) => h.key === "Content-Security-Policy"
|
||||
);
|
||||
|
||||
expect(cspHeader).toBeDefined();
|
||||
|
||||
@ -122,7 +138,9 @@ describe("Security Headers Configuration", () => {
|
||||
expect(csp).toContain("upgrade-insecure-requests");
|
||||
|
||||
// Next.js compatibility directives
|
||||
expect(csp).toContain("script-src 'self' 'unsafe-eval' 'unsafe-inline'");
|
||||
expect(csp).toContain(
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'"
|
||||
);
|
||||
expect(csp).toContain("style-src 'self' 'unsafe-inline'");
|
||||
expect(csp).toContain("img-src 'self' data: https:");
|
||||
expect(csp).toContain("font-src 'self' data:");
|
||||
@ -136,8 +154,12 @@ describe("Security Headers Configuration", () => {
|
||||
const nextConfig = await import("../../next.config.js");
|
||||
const headers = await nextConfig.default.headers();
|
||||
|
||||
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
|
||||
const permissionsHeader = securityConfig?.headers.find(h => h.key === "Permissions-Policy");
|
||||
const securityConfig = headers.find(
|
||||
(h) => h.source === "/(.*)" && h.headers.length > 1
|
||||
);
|
||||
const permissionsHeader = securityConfig?.headers.find(
|
||||
(h) => h.key === "Permissions-Policy"
|
||||
);
|
||||
|
||||
expect(permissionsHeader).toBeDefined();
|
||||
|
||||
@ -153,4 +175,4 @@ describe("Security Headers Configuration", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
545
tests/integration/security-monitoring-api.test.ts
Normal file
545
tests/integration/security-monitoring-api.test.ts
Normal file
@ -0,0 +1,545 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { GET, POST } from "@/app/api/admin/security-monitoring/route";
|
||||
import {
|
||||
GET as AlertsGET,
|
||||
POST as AlertsPOST,
|
||||
} from "@/app/api/admin/security-monitoring/alerts/route";
|
||||
import { GET as ExportGET } from "@/app/api/admin/security-monitoring/export/route";
|
||||
import { POST as ThreatAnalysisPOST } from "@/app/api/admin/security-monitoring/threat-analysis/route";
|
||||
|
||||
// Mock next-auth
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock security monitoring
|
||||
vi.mock("@/lib/securityMonitoring", () => ({
|
||||
securityMonitoring: {
|
||||
getSecurityMetrics: vi.fn(),
|
||||
getActiveAlerts: vi.fn(),
|
||||
getConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
acknowledgeAlert: vi.fn(),
|
||||
exportSecurityData: vi.fn(),
|
||||
calculateIPThreatLevel: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock security audit logger
|
||||
vi.mock("@/lib/securityAuditLogger", () => ({
|
||||
createAuditContext: vi.fn(),
|
||||
securityAuditLogger: {
|
||||
logPlatformAdmin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { securityMonitoring } = await import("@/lib/securityMonitoring");
|
||||
const { createAuditContext, securityAuditLogger } = await import(
|
||||
"@/lib/securityAuditLogger"
|
||||
);
|
||||
|
||||
const mockPlatformUserSession = {
|
||||
user: {
|
||||
id: "platform-user-1",
|
||||
email: "admin@platform.com",
|
||||
isPlatformUser: true,
|
||||
platformRole: "ADMIN",
|
||||
},
|
||||
};
|
||||
|
||||
const mockRegularUserSession = {
|
||||
user: {
|
||||
id: "user-1",
|
||||
email: "user@company.com",
|
||||
isPlatformUser: false,
|
||||
companyId: "company-1",
|
||||
},
|
||||
};
|
||||
|
||||
describe("Security Monitoring API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(createAuditContext).mockResolvedValue({
|
||||
userId: "platform-user-1",
|
||||
requestId: "test-request-123",
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/security-monitoring", () => {
|
||||
it("should return security metrics for platform admin", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
|
||||
const mockMetrics = {
|
||||
totalEvents: 100,
|
||||
criticalEvents: 5,
|
||||
activeAlerts: 3,
|
||||
resolvedAlerts: 10,
|
||||
securityScore: 85,
|
||||
threatLevel: "MODERATE",
|
||||
eventsByType: { AUTHENTICATION: 50, RATE_LIMITING: 30 },
|
||||
alertsByType: { BRUTE_FORCE_ATTACK: 2, RATE_LIMIT_BREACH: 1 },
|
||||
topThreats: [{ type: "BRUTE_FORCE_ATTACK", count: 2 }],
|
||||
geoDistribution: { USA: 60, GBR: 40 },
|
||||
timeDistribution: Array.from({ length: 24 }, (_, i) => ({
|
||||
hour: i,
|
||||
count: Math.floor(Math.random() * 10),
|
||||
})),
|
||||
userRiskScores: [
|
||||
{ userId: "user-1", email: "test@test.com", riskScore: 75 },
|
||||
],
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
thresholds: {
|
||||
failedLoginsPerMinute: 5,
|
||||
failedLoginsPerHour: 20,
|
||||
rateLimitViolationsPerMinute: 10,
|
||||
cspViolationsPerMinute: 15,
|
||||
adminActionsPerHour: 25,
|
||||
massDataAccessThreshold: 100,
|
||||
suspiciousIPThreshold: 10,
|
||||
},
|
||||
alerting: {
|
||||
enabled: true,
|
||||
channels: ["EMAIL"],
|
||||
suppressDuplicateMinutes: 10,
|
||||
escalationTimeoutMinutes: 60,
|
||||
},
|
||||
retention: {
|
||||
alertRetentionDays: 90,
|
||||
metricsRetentionDays: 365,
|
||||
},
|
||||
};
|
||||
|
||||
const mockAlerts = [
|
||||
{
|
||||
id: "alert-1",
|
||||
timestamp: new Date(),
|
||||
severity: "HIGH",
|
||||
type: "BRUTE_FORCE_ATTACK",
|
||||
title: "Brute Force Attack Detected",
|
||||
description: "Multiple failed login attempts",
|
||||
eventType: "AUTHENTICATION",
|
||||
context: { ipAddress: "192.168.1.100" },
|
||||
metadata: {},
|
||||
acknowledged: false,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityMonitoring.getSecurityMetrics).mockResolvedValue(
|
||||
mockMetrics
|
||||
);
|
||||
vi.mocked(securityMonitoring.getConfig).mockReturnValue(mockConfig);
|
||||
vi.mocked(securityMonitoring.getActiveAlerts).mockReturnValue(mockAlerts);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring"
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data).toMatchObject({
|
||||
metrics: mockMetrics,
|
||||
alerts: mockAlerts,
|
||||
config: mockConfig,
|
||||
timeRange: expect.any(Object),
|
||||
});
|
||||
|
||||
expect(securityAuditLogger.logPlatformAdmin).toHaveBeenCalledWith(
|
||||
"security_monitoring_access",
|
||||
"SUCCESS",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject non-platform users", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockRegularUserSession);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring"
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Forbidden");
|
||||
});
|
||||
|
||||
it("should reject unauthenticated requests", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring"
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("should handle query parameters correctly", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
vi.mocked(securityMonitoring.getSecurityMetrics).mockResolvedValue(
|
||||
{} as any
|
||||
);
|
||||
vi.mocked(securityMonitoring.getConfig).mockReturnValue({} as any);
|
||||
vi.mocked(securityMonitoring.getActiveAlerts).mockReturnValue([]);
|
||||
|
||||
const url =
|
||||
"http://localhost:3000/api/admin/security-monitoring?startDate=2024-01-01T00:00:00Z&endDate=2024-01-02T00:00:00Z&companyId=company-1&severity=HIGH";
|
||||
const request = new NextRequest(url);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(securityMonitoring.getSecurityMetrics).toHaveBeenCalledWith(
|
||||
{
|
||||
start: new Date("2024-01-01T00:00:00Z"),
|
||||
end: new Date("2024-01-02T00:00:00Z"),
|
||||
},
|
||||
"company-1"
|
||||
);
|
||||
expect(securityMonitoring.getActiveAlerts).toHaveBeenCalledWith("HIGH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/security-monitoring", () => {
|
||||
it("should update security configuration", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
|
||||
const newConfig = {
|
||||
thresholds: {
|
||||
failedLoginsPerMinute: 3,
|
||||
failedLoginsPerHour: 15,
|
||||
},
|
||||
alerting: {
|
||||
enabled: false,
|
||||
channels: ["EMAIL", "SLACK"],
|
||||
},
|
||||
};
|
||||
|
||||
const updatedConfig = {
|
||||
thresholds: {
|
||||
failedLoginsPerMinute: 3,
|
||||
failedLoginsPerHour: 15,
|
||||
rateLimitViolationsPerMinute: 10,
|
||||
cspViolationsPerMinute: 15,
|
||||
adminActionsPerHour: 25,
|
||||
massDataAccessThreshold: 100,
|
||||
suspiciousIPThreshold: 10,
|
||||
},
|
||||
alerting: {
|
||||
enabled: false,
|
||||
channels: ["EMAIL", "SLACK"],
|
||||
suppressDuplicateMinutes: 10,
|
||||
escalationTimeoutMinutes: 60,
|
||||
},
|
||||
retention: {
|
||||
alertRetentionDays: 90,
|
||||
metricsRetentionDays: 365,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(securityMonitoring.getConfig).mockReturnValue(updatedConfig);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(newConfig),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.config).toEqual(updatedConfig);
|
||||
|
||||
expect(securityMonitoring.updateConfig).toHaveBeenCalledWith(newConfig);
|
||||
expect(securityAuditLogger.logPlatformAdmin).toHaveBeenCalledWith(
|
||||
"security_monitoring_config_update",
|
||||
"SUCCESS",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
{ configChanges: newConfig }
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate configuration input", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
|
||||
const invalidConfig = {
|
||||
thresholds: {
|
||||
failedLoginsPerMinute: -1, // Invalid: negative number
|
||||
failedLoginsPerHour: 2000, // Invalid: too large
|
||||
},
|
||||
};
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(invalidConfig),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Invalid configuration");
|
||||
expect(data.details).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/security-monitoring/alerts", () => {
|
||||
it("should return filtered alerts", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
|
||||
const mockAlerts = [
|
||||
{
|
||||
id: "alert-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: "HIGH",
|
||||
type: "BRUTE_FORCE_ATTACK",
|
||||
title: "Brute Force Attack",
|
||||
description: "Multiple failed logins",
|
||||
eventType: "AUTHENTICATION",
|
||||
context: {},
|
||||
metadata: {},
|
||||
acknowledged: false,
|
||||
},
|
||||
{
|
||||
id: "alert-2",
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: "MEDIUM",
|
||||
type: "RATE_LIMIT_BREACH",
|
||||
title: "Rate Limit Exceeded",
|
||||
description: "Too many requests",
|
||||
eventType: "RATE_LIMITING",
|
||||
context: {},
|
||||
metadata: {},
|
||||
acknowledged: false,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityMonitoring.getActiveAlerts).mockReturnValue(mockAlerts);
|
||||
|
||||
const url =
|
||||
"http://localhost:3000/api/admin/security-monitoring/alerts?severity=HIGH&limit=10&offset=0";
|
||||
const request = new NextRequest(url);
|
||||
const response = await AlertsGET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.alerts).toEqual(mockAlerts);
|
||||
expect(data.total).toBe(2);
|
||||
expect(data.limit).toBe(10);
|
||||
expect(data.offset).toBe(0);
|
||||
|
||||
expect(securityMonitoring.getActiveAlerts).toHaveBeenCalledWith("HIGH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/security-monitoring/alerts", () => {
|
||||
it("should acknowledge alert", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
vi.mocked(securityMonitoring.acknowledgeAlert).mockResolvedValue(true);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring/alerts",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
alertId: "alert-123",
|
||||
action: "acknowledge",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const response = await AlertsPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
expect(securityMonitoring.acknowledgeAlert).toHaveBeenCalledWith(
|
||||
"alert-123",
|
||||
"platform-user-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle non-existent alert", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
vi.mocked(securityMonitoring.acknowledgeAlert).mockResolvedValue(false);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring/alerts",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
alertId: "non-existent",
|
||||
action: "acknowledge",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const response = await AlertsPOST(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Alert not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/security-monitoring/export", () => {
|
||||
it("should export security data as JSON", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
|
||||
const mockExportData = JSON.stringify([
|
||||
{
|
||||
id: "alert-1",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
severity: "HIGH",
|
||||
type: "BRUTE_FORCE_ATTACK",
|
||||
title: "Test Alert",
|
||||
description: "Test Description",
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(securityMonitoring.exportSecurityData).mockReturnValue(
|
||||
mockExportData
|
||||
);
|
||||
|
||||
const url =
|
||||
"http://localhost:3000/api/admin/security-monitoring/export?format=json&type=alerts&startDate=2024-01-01T00:00:00Z&endDate=2024-01-02T00:00:00Z";
|
||||
const request = new NextRequest(url);
|
||||
const response = await ExportGET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||
expect(response.headers.get("Content-Disposition")).toContain(
|
||||
"attachment"
|
||||
);
|
||||
|
||||
const data = await response.text();
|
||||
expect(data).toBe(mockExportData);
|
||||
});
|
||||
|
||||
it("should export security data as CSV", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
|
||||
const mockCsvData =
|
||||
"timestamp,severity,type,title\n2024-01-01T00:00:00.000Z,HIGH,BRUTE_FORCE_ATTACK,Test Alert";
|
||||
vi.mocked(securityMonitoring.exportSecurityData).mockReturnValue(
|
||||
mockCsvData
|
||||
);
|
||||
|
||||
const url =
|
||||
"http://localhost:3000/api/admin/security-monitoring/export?format=csv&type=alerts&startDate=2024-01-01T00:00:00Z&endDate=2024-01-02T00:00:00Z";
|
||||
const request = new NextRequest(url);
|
||||
const response = await ExportGET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/csv");
|
||||
|
||||
const data = await response.text();
|
||||
expect(data).toBe(mockCsvData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/admin/security-monitoring/threat-analysis", () => {
|
||||
it("should perform IP threat analysis", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
|
||||
const mockThreatAnalysis = {
|
||||
threatLevel: "HIGH",
|
||||
riskFactors: ["Multiple failed logins", "Rate limit violations"],
|
||||
recommendations: ["Block IP address", "Investigate source"],
|
||||
};
|
||||
|
||||
const mockMetrics = {
|
||||
securityScore: 65,
|
||||
threatLevel: "HIGH",
|
||||
activeAlerts: 5,
|
||||
criticalEvents: 2,
|
||||
topThreats: [],
|
||||
geoDistribution: {},
|
||||
userRiskScores: [],
|
||||
};
|
||||
|
||||
vi.mocked(securityMonitoring.calculateIPThreatLevel).mockResolvedValue(
|
||||
mockThreatAnalysis
|
||||
);
|
||||
vi.mocked(securityMonitoring.getSecurityMetrics).mockResolvedValue(
|
||||
mockMetrics
|
||||
);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring/threat-analysis",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
ipAddress: "192.168.1.100",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const response = await ThreatAnalysisPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.ipThreatAnalysis).toMatchObject({
|
||||
ipAddress: "192.168.1.100",
|
||||
...mockThreatAnalysis,
|
||||
});
|
||||
expect(data.overallThreatLandscape).toBeDefined();
|
||||
|
||||
expect(securityMonitoring.calculateIPThreatLevel).toHaveBeenCalledWith(
|
||||
"192.168.1.100"
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate IP address format", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockPlatformUserSession);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/admin/security-monitoring/threat-analysis",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
ipAddress: "invalid-ip",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const response = await ThreatAnalysisPOST(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Invalid request");
|
||||
expect(data.details).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -14,10 +14,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { processSessionImports } from "@/lib/importProcessor";
|
||||
import { processUnprocessedSessions } from "@/lib/processingScheduler";
|
||||
import { createBatchJob, checkBatchStatuses, processCompletedBatches } from "@/lib/batchProcessor";
|
||||
import {
|
||||
createBatchJob,
|
||||
checkBatchStatuses,
|
||||
processCompletedBatches,
|
||||
} from "@/lib/batchProcessor";
|
||||
import { transcriptFetcher } from "@/lib/transcriptFetcher";
|
||||
import { parseTranscriptMessages } from "@/lib/transcriptParser";
|
||||
import type { Company, SessionImport, Session, User, AIBatchRequest } from "@prisma/client";
|
||||
import type {
|
||||
Company,
|
||||
SessionImport,
|
||||
Session,
|
||||
User,
|
||||
AIBatchRequest,
|
||||
} from "@prisma/client";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("@/lib/transcriptFetcher");
|
||||
@ -127,7 +137,8 @@ Chat ended at 10:15 AM
|
||||
});
|
||||
expect(session?.messages[1]).toMatchObject({
|
||||
role: "Assistant",
|
||||
content: "Hi! I'd be happy to help you with your vacation request. How many days are you planning to take off?",
|
||||
content:
|
||||
"Hi! I'd be happy to help you with your vacation request. How many days are you planning to take off?",
|
||||
order: 1,
|
||||
});
|
||||
|
||||
@ -174,7 +185,9 @@ Chat ended at 10:15 AM
|
||||
});
|
||||
|
||||
// Mock transcript fetching to throw error
|
||||
vi.mocked(transcriptFetcher).mockRejectedValueOnce(new Error("Network error"));
|
||||
vi.mocked(transcriptFetcher).mockRejectedValueOnce(
|
||||
new Error("Network error")
|
||||
);
|
||||
|
||||
await processSessionImports(testCompany.id);
|
||||
|
||||
@ -207,7 +220,11 @@ Chat ended at 10:15 AM
|
||||
messages: {
|
||||
create: [
|
||||
{ role: "User", content: "I need 10 days of vacation", order: 0 },
|
||||
{ role: "Assistant", content: "I'll help you with that", order: 1 },
|
||||
{
|
||||
role: "Assistant",
|
||||
content: "I'll help you with that",
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@ -277,7 +294,9 @@ Chat ended at 10:15 AM
|
||||
|
||||
// Mock OpenAI batch status check
|
||||
const openai = await import("openai");
|
||||
vi.mocked(openai.default.prototype.batches.retrieve).mockResolvedValueOnce({
|
||||
vi.mocked(
|
||||
openai.default.prototype.batches.retrieve
|
||||
).mockResolvedValueOnce({
|
||||
id: "batch_xyz789",
|
||||
status: "completed",
|
||||
output_file_id: "file-output",
|
||||
@ -323,19 +342,21 @@ Chat ended at 10:15 AM
|
||||
response: {
|
||||
status_code: 200,
|
||||
body: {
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
sentiment: "POSITIVE",
|
||||
category: "LEAVE_VACATION",
|
||||
summary: "User requesting 10 days vacation",
|
||||
questions: [
|
||||
"How do I access the HR portal?",
|
||||
"When should my vacation end?"
|
||||
],
|
||||
}),
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
sentiment: "POSITIVE",
|
||||
category: "LEAVE_VACATION",
|
||||
summary: "User requesting 10 days vacation",
|
||||
questions: [
|
||||
"How do I access the HR portal?",
|
||||
"When should my vacation end?",
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
}],
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
@ -344,7 +365,9 @@ Chat ended at 10:15 AM
|
||||
},
|
||||
},
|
||||
},
|
||||
].map(r => JSON.stringify(r)).join("\n");
|
||||
]
|
||||
.map((r) => JSON.stringify(r))
|
||||
.join("\n");
|
||||
|
||||
// Mock OpenAI file content retrieval
|
||||
const openai = await import("openai");
|
||||
@ -372,7 +395,9 @@ Chat ended at 10:15 AM
|
||||
expect(updatedSession?.sessionQuestions).toHaveLength(2);
|
||||
|
||||
// Verify questions were extracted
|
||||
const questions = updatedSession?.sessionQuestions.map(sq => sq.question.content);
|
||||
const questions = updatedSession?.sessionQuestions.map(
|
||||
(sq) => sq.question.content
|
||||
);
|
||||
expect(questions).toContain("How do I access the HR portal?");
|
||||
expect(questions).toContain("When should my vacation end?");
|
||||
|
||||
@ -503,28 +528,36 @@ Chat ended at 10:15 AM
|
||||
});
|
||||
|
||||
// Step 5: Process batch results
|
||||
const mockResults = [{
|
||||
custom_id: session!.id,
|
||||
response: {
|
||||
status_code: 200,
|
||||
body: {
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
sentiment: "POSITIVE",
|
||||
category: "LEAVE_VACATION",
|
||||
summary: "User successfully requested vacation time",
|
||||
questions: ["How do I access the HR portal?"],
|
||||
}),
|
||||
const mockResults = [
|
||||
{
|
||||
custom_id: session!.id,
|
||||
response: {
|
||||
status_code: 200,
|
||||
body: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
sentiment: "POSITIVE",
|
||||
category: "LEAVE_VACATION",
|
||||
summary: "User successfully requested vacation time",
|
||||
questions: ["How do I access the HR portal?"],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 100,
|
||||
total_tokens: 300,
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 200, completion_tokens: 100, total_tokens: 300 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}];
|
||||
];
|
||||
|
||||
vi.mocked(openai.default.prototype.files.content).mockResolvedValueOnce({
|
||||
text: async () => mockResults.map(r => JSON.stringify(r)).join("\n"),
|
||||
text: async () => mockResults.map((r) => JSON.stringify(r)).join("\n"),
|
||||
} as any);
|
||||
|
||||
await processCompletedBatches(testCompany.id);
|
||||
@ -541,10 +574,12 @@ Chat ended at 10:15 AM
|
||||
|
||||
expect(finalSession?.sentiment).toBe("POSITIVE");
|
||||
expect(finalSession?.category).toBe("LEAVE_VACATION");
|
||||
expect(finalSession?.summary).toBe("User successfully requested vacation time");
|
||||
expect(finalSession?.summary).toBe(
|
||||
"User successfully requested vacation time"
|
||||
);
|
||||
expect(finalSession?.sessionQuestions).toHaveLength(1);
|
||||
expect(finalSession?.aiProcessingRequests).toHaveLength(1);
|
||||
expect(finalSession?.aiProcessingRequests[0].success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user