mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 10:52:08 +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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -321,4 +321,4 @@ describe("CSRF Hooks", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,7 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { generateCSRFToken, verifyCSRFToken, CSRFProtection, CSRF_CONFIG } from "../../lib/csrf";
|
||||
import {
|
||||
generateCSRFToken,
|
||||
verifyCSRFToken,
|
||||
CSRFProtection,
|
||||
CSRF_CONFIG,
|
||||
} from "../../lib/csrf";
|
||||
|
||||
// Mock Next.js modules
|
||||
vi.mock("next/headers", () => ({
|
||||
@ -237,4 +242,4 @@ describe("CSRF Protection", () => {
|
||||
expect(typeof CSRF_CONFIG.cookie.secure).toBe("boolean");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
155
tests/unit/enhanced-csp.test.ts
Normal file
155
tests/unit/enhanced-csp.test.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
buildCSP,
|
||||
validateCSP,
|
||||
testCSPImplementation,
|
||||
generateNonce,
|
||||
detectCSPBypass,
|
||||
type CSPConfig,
|
||||
} from "../../lib/csp";
|
||||
import { cspMonitoring } from "../../lib/csp-monitoring";
|
||||
|
||||
describe("Enhanced CSP Implementation", () => {
|
||||
describe("CSP Building", () => {
|
||||
it("should build development CSP with unsafe directives", () => {
|
||||
const csp = buildCSP({ isDevelopment: true });
|
||||
|
||||
expect(csp).toContain("'unsafe-eval'");
|
||||
expect(csp).toContain("'unsafe-inline'");
|
||||
expect(csp).toContain("wss:");
|
||||
expect(csp).toContain("ws:");
|
||||
});
|
||||
|
||||
it("should build production CSP with nonce-based execution", () => {
|
||||
const nonce = generateNonce();
|
||||
const csp = buildCSP({
|
||||
isDevelopment: false,
|
||||
nonce,
|
||||
strictMode: true,
|
||||
});
|
||||
|
||||
expect(csp).toContain(`'nonce-${nonce}'`);
|
||||
expect(csp).toContain("'strict-dynamic'");
|
||||
expect(csp).not.toContain("'unsafe-inline'");
|
||||
expect(csp).not.toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it("should handle external domains in strict mode", () => {
|
||||
const config: CSPConfig = {
|
||||
isDevelopment: false,
|
||||
strictMode: true,
|
||||
allowedExternalDomains: [
|
||||
"https://api.openai.com",
|
||||
"https://example.com",
|
||||
],
|
||||
};
|
||||
|
||||
const csp = buildCSP(config);
|
||||
|
||||
expect(csp).toContain("https://api.openai.com");
|
||||
expect(csp).toContain("https://example.com");
|
||||
|
||||
// Check that connect-src doesn't have broad https: allowlist (only specific domains)
|
||||
const connectSrcMatch = csp.match(/connect-src[^;]+/);
|
||||
// Should not contain "https:" as a standalone directive (which would allow all HTTPS)
|
||||
expect(connectSrcMatch?.[0]).not.toMatch(/\bhttps:\s/);
|
||||
expect(connectSrcMatch?.[0]).not.toMatch(/\shttps:$/);
|
||||
// But should contain specific domains
|
||||
expect(connectSrcMatch?.[0]).toContain("https://api.openai.com");
|
||||
});
|
||||
|
||||
it("should include proper map tile sources", () => {
|
||||
const csp = buildCSP({ isDevelopment: false });
|
||||
|
||||
expect(csp).toContain("https://*.basemaps.cartocdn.com");
|
||||
expect(csp).toContain("https://*.openstreetmap.org");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSP Validation", () => {
|
||||
it("should validate development CSP with appropriate warnings", () => {
|
||||
const csp = buildCSP({ isDevelopment: true });
|
||||
const validation = validateCSP(csp);
|
||||
|
||||
expect(validation.isValid).toBe(true);
|
||||
expect(validation.warnings.length).toBeGreaterThan(0);
|
||||
expect(validation.warnings.some((w) => w.includes("unsafe-eval"))).toBe(
|
||||
true
|
||||
);
|
||||
expect(validation.securityScore).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should validate production CSP with higher security score", () => {
|
||||
const nonce = generateNonce();
|
||||
const csp = buildCSP({
|
||||
isDevelopment: false,
|
||||
nonce,
|
||||
strictMode: true,
|
||||
reportUri: "/api/csp-report",
|
||||
});
|
||||
const validation = validateCSP(csp, { strictMode: true });
|
||||
|
||||
expect(validation.isValid).toBe(true);
|
||||
expect(validation.securityScore).toBeGreaterThan(80);
|
||||
expect(validation.recommendations).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSP Bypass Detection", () => {
|
||||
it("should detect JavaScript protocol attempts", () => {
|
||||
const content = "javascript:alert(1)";
|
||||
const detection = detectCSPBypass(content);
|
||||
|
||||
expect(detection.isDetected).toBe(true);
|
||||
expect(detection.riskLevel).toBe("high");
|
||||
expect(detection.patterns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should detect data URI script injection", () => {
|
||||
const content = "data:text/javascript,alert(1)";
|
||||
const detection = detectCSPBypass(content);
|
||||
|
||||
expect(detection.isDetected).toBe(true);
|
||||
expect(detection.riskLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("should detect eval injection attempts", () => {
|
||||
const content = "eval('malicious code')";
|
||||
const detection = detectCSPBypass(content);
|
||||
|
||||
expect(detection.isDetected).toBe(true);
|
||||
expect(detection.riskLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("should not flag legitimate JavaScript", () => {
|
||||
const content = "const x = document.getElementById('safe');";
|
||||
const detection = detectCSPBypass(content);
|
||||
|
||||
expect(detection.isDetected).toBe(false);
|
||||
expect(detection.riskLevel).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nonce Generation", () => {
|
||||
it("should generate cryptographically secure nonces", () => {
|
||||
const nonce1 = generateNonce();
|
||||
const nonce2 = generateNonce();
|
||||
|
||||
expect(nonce1).not.toBe(nonce2);
|
||||
expect(nonce1.length).toBeGreaterThan(10);
|
||||
expect(typeof nonce1).toBe("string");
|
||||
|
||||
// Should be base64 encoded
|
||||
expect(() => atob(nonce1)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should generate unique nonces", () => {
|
||||
const nonces = new Set();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
nonces.add(generateNonce());
|
||||
}
|
||||
|
||||
expect(nonces.size).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -370,4 +370,4 @@ describe("Security Header Integration", () => {
|
||||
expect(cspValue).toContain("style-src 'self' 'unsafe-inline'");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,14 +12,14 @@ describe("Password Reset Token Security", () => {
|
||||
|
||||
describe("Token Generation Security Properties", () => {
|
||||
it("should generate tokens with 64 characters (32 bytes as hex)", () => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
expect(token).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("should generate unique tokens on each call", () => {
|
||||
const token1 = crypto.randomBytes(32).toString('hex');
|
||||
const token2 = crypto.randomBytes(32).toString('hex');
|
||||
const token3 = crypto.randomBytes(32).toString('hex');
|
||||
const token1 = crypto.randomBytes(32).toString("hex");
|
||||
const token2 = crypto.randomBytes(32).toString("hex");
|
||||
const token3 = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
expect(token2).not.toBe(token3);
|
||||
@ -32,7 +32,7 @@ describe("Password Reset Token Security", () => {
|
||||
|
||||
// Generate multiple tokens to check for patterns
|
||||
for (let i = 0; i < numTokens; i++) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
tokens.add(token);
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ describe("Password Reset Token Security", () => {
|
||||
});
|
||||
|
||||
it("should generate tokens with hex characters only", () => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const hexPattern = /^[0-9a-f]+$/;
|
||||
expect(token).toMatch(hexPattern);
|
||||
});
|
||||
@ -49,7 +49,7 @@ describe("Password Reset Token Security", () => {
|
||||
it("should have sufficient entropy to prevent brute force attacks", () => {
|
||||
// 32 bytes = 256 bits of entropy
|
||||
// This provides 2^256 possible combinations
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// Verify we have the expected length for 256-bit security
|
||||
expect(token).toHaveLength(64);
|
||||
@ -66,7 +66,7 @@ describe("Password Reset Token Security", () => {
|
||||
|
||||
it("should be significantly more secure than Math.random() approach", () => {
|
||||
// Generate tokens using both methods for comparison
|
||||
const secureToken = crypto.randomBytes(32).toString('hex');
|
||||
const secureToken = crypto.randomBytes(32).toString("hex");
|
||||
const weakToken = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Secure token should be much longer
|
||||
@ -88,7 +88,7 @@ describe("Password Reset Token Security", () => {
|
||||
|
||||
// Generate many tokens to test collision resistance
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
expect(tokens.has(token)).toBe(false); // No collisions
|
||||
tokens.add(token);
|
||||
}
|
||||
@ -103,7 +103,7 @@ describe("Password Reset Token Security", () => {
|
||||
|
||||
// Generate 1000 tokens
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
crypto.randomBytes(32).toString('hex');
|
||||
crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
@ -117,7 +117,7 @@ describe("Password Reset Token Security", () => {
|
||||
describe("Token Format Validation", () => {
|
||||
it("should always produce lowercase hex", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
expect(token).toBe(token.toLowerCase());
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
}
|
||||
@ -127,16 +127,16 @@ describe("Password Reset Token Security", () => {
|
||||
const tokens = [];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.push(crypto.randomBytes(32).toString('hex'));
|
||||
tokens.push(crypto.randomBytes(32).toString("hex"));
|
||||
}
|
||||
|
||||
// Check that tokens don't all start with same character
|
||||
const firstChars = new Set(tokens.map(t => t[0]));
|
||||
const firstChars = new Set(tokens.map((t) => t[0]));
|
||||
expect(firstChars.size).toBeGreaterThan(1);
|
||||
|
||||
// Check that we don't have obvious patterns like all starting with '0'
|
||||
const zeroStart = tokens.filter(t => t.startsWith('0')).length;
|
||||
const zeroStart = tokens.filter((t) => t.startsWith("0")).length;
|
||||
expect(zeroStart).toBeLessThan(tokens.length * 0.8); // Should be roughly 1/16
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
553
tests/unit/security-audit-logging.test.ts
Normal file
553
tests/unit/security-audit-logging.test.ts
Normal file
@ -0,0 +1,553 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import {
|
||||
securityAuditLogger,
|
||||
SecurityEventType,
|
||||
AuditOutcome,
|
||||
AuditSeverity,
|
||||
createAuditMetadata,
|
||||
createAuditContext,
|
||||
} from "../../lib/securityAuditLogger";
|
||||
import {
|
||||
AuditLogRetentionManager,
|
||||
DEFAULT_RETENTION_POLICIES,
|
||||
} from "../../lib/auditLogRetention";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Mock Prisma
|
||||
vi.mock("../../lib/prisma", () => ({
|
||||
prisma: {
|
||||
securityAuditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Security Audit Logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-15T10:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("SecurityAuditLogger", () => {
|
||||
it("should log authentication events with correct structure", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockResolvedValueOnce({} as any);
|
||||
|
||||
const context = {
|
||||
userId: "user-123",
|
||||
companyId: "company-456",
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
metadata: { action: "login" },
|
||||
};
|
||||
|
||||
await securityAuditLogger.logAuthentication(
|
||||
"user_login",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
action: "user_login",
|
||||
outcome: AuditOutcome.SUCCESS,
|
||||
severity: AuditSeverity.INFO,
|
||||
userId: "user-123",
|
||||
companyId: "company-456",
|
||||
platformUserId: null,
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
country: null,
|
||||
sessionId: null,
|
||||
requestId: null,
|
||||
metadata: { action: "login" },
|
||||
errorMessage: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign correct severity for failed authentication", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockResolvedValueOnce({} as any);
|
||||
|
||||
await securityAuditLogger.logAuthentication(
|
||||
"user_login_failed",
|
||||
AuditOutcome.FAILURE,
|
||||
{ ipAddress: "192.168.1.1" },
|
||||
"Invalid credentials"
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
severity: AuditSeverity.MEDIUM,
|
||||
errorMessage: "Invalid credentials",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign high severity for blocked authentication", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockResolvedValueOnce({} as any);
|
||||
|
||||
await securityAuditLogger.logAuthentication(
|
||||
"user_login_blocked",
|
||||
AuditOutcome.BLOCKED,
|
||||
{ ipAddress: "192.168.1.1" },
|
||||
"Account suspended"
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
severity: AuditSeverity.HIGH,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should log platform admin events with critical severity", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockResolvedValueOnce({} as any);
|
||||
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"company_suspended",
|
||||
AuditOutcome.SUCCESS,
|
||||
{
|
||||
platformUserId: "admin-123",
|
||||
companyId: "company-456",
|
||||
ipAddress: "10.0.0.1",
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
eventType: SecurityEventType.PLATFORM_ADMIN,
|
||||
severity: AuditSeverity.HIGH,
|
||||
platformUserId: "admin-123",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle logging errors gracefully", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockRejectedValueOnce(new Error("Database error"));
|
||||
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
securityAuditLogger.logAuthentication(
|
||||
"test_action",
|
||||
AuditOutcome.SUCCESS,
|
||||
{ ipAddress: "127.0.0.1" }
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to write audit log:",
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should respect audit logging disabled flag", async () => {
|
||||
const originalEnv = process.env.AUDIT_LOGGING_ENABLED;
|
||||
process.env.AUDIT_LOGGING_ENABLED = "false";
|
||||
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
|
||||
// Create new instance to pick up environment change
|
||||
const disabledLogger = new (
|
||||
await import("../../lib/securityAuditLogger")
|
||||
).SecurityAuditLogger();
|
||||
|
||||
await (disabledLogger as any).log({
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
action: "test",
|
||||
outcome: AuditOutcome.SUCCESS,
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
|
||||
process.env.AUDIT_LOGGING_ENABLED = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuditMetadata", () => {
|
||||
it("should sanitize sensitive data", () => {
|
||||
const input = {
|
||||
email: "user@example.com",
|
||||
password: "secret123",
|
||||
token: "jwt-token",
|
||||
count: 5,
|
||||
isValid: true,
|
||||
nestedObject: { key: "value" },
|
||||
arrayOfObjects: [{ id: 1 }, { id: 2 }],
|
||||
arrayOfStrings: ["a", "b", "c"],
|
||||
};
|
||||
|
||||
const result = createAuditMetadata(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
email: "user@example.com",
|
||||
password: "secret123",
|
||||
token: "jwt-token",
|
||||
count: 5,
|
||||
isValid: true,
|
||||
nestedObject: "[Object]",
|
||||
arrayOfObjects: ["[Object]", "[Object]"],
|
||||
arrayOfStrings: ["a", "b", "c"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty and null values", () => {
|
||||
const input = {
|
||||
emptyString: "",
|
||||
nullValue: null,
|
||||
undefinedValue: undefined,
|
||||
zeroNumber: 0,
|
||||
falseBool: false,
|
||||
};
|
||||
|
||||
const result = createAuditMetadata(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
emptyString: "",
|
||||
zeroNumber: 0,
|
||||
falseBool: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuditContext", () => {
|
||||
it("should extract context from NextRequest", async () => {
|
||||
const mockRequest = new NextRequest("http://localhost:3000/api/test", {
|
||||
headers: {
|
||||
"user-agent": "Test Agent",
|
||||
"x-forwarded-for": "203.0.113.1",
|
||||
"x-request-id": "req-123",
|
||||
},
|
||||
});
|
||||
|
||||
const context = await createAuditContext(mockRequest);
|
||||
|
||||
expect(context).toEqual({
|
||||
requestId: expect.any(String),
|
||||
ipAddress: "203.0.113.1",
|
||||
userAgent: "Test Agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("should include session information when provided", async () => {
|
||||
const mockSession = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "user@example.com",
|
||||
companyId: "company-456",
|
||||
role: "USER",
|
||||
},
|
||||
};
|
||||
|
||||
const context = await createAuditContext(undefined, mockSession);
|
||||
|
||||
expect(context).toEqual({
|
||||
requestId: expect.any(String),
|
||||
userId: "user-123",
|
||||
companyId: "company-456",
|
||||
});
|
||||
});
|
||||
|
||||
it("should detect platform users", async () => {
|
||||
const mockSession = {
|
||||
user: {
|
||||
id: "admin-123",
|
||||
email: "admin@platform.com",
|
||||
isPlatformUser: true,
|
||||
},
|
||||
};
|
||||
|
||||
const context = await createAuditContext(undefined, mockSession);
|
||||
|
||||
expect(context).toEqual({
|
||||
requestId: expect.any(String),
|
||||
userId: "admin-123",
|
||||
platformUserId: "admin-123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuditLogRetentionManager", () => {
|
||||
it("should validate retention policies correctly", async () => {
|
||||
const manager = new AuditLogRetentionManager();
|
||||
const validation = await manager.validateRetentionPolicies();
|
||||
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(validation.errors).toHaveLength(0);
|
||||
expect(validation.warnings.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("should detect invalid retention policies", async () => {
|
||||
const invalidPolicies = [
|
||||
{
|
||||
name: "",
|
||||
maxAgeDays: 30,
|
||||
},
|
||||
{
|
||||
name: "Invalid Age",
|
||||
maxAgeDays: -5,
|
||||
},
|
||||
];
|
||||
|
||||
const manager = new AuditLogRetentionManager(invalidPolicies);
|
||||
const validation = await manager.validateRetentionPolicies();
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should calculate retention statistics", async () => {
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
const mockGroupBy = vi.mocked(prisma.securityAuditLog.groupBy);
|
||||
const mockFindFirst = vi.mocked(prisma.securityAuditLog.findFirst);
|
||||
|
||||
mockCount
|
||||
.mockResolvedValueOnce(1000) // total logs
|
||||
.mockResolvedValueOnce(50) // last 24 hours
|
||||
.mockResolvedValueOnce(200) // last 7 days
|
||||
.mockResolvedValueOnce(500) // last 30 days
|
||||
.mockResolvedValueOnce(800) // last 90 days
|
||||
.mockResolvedValueOnce(950) // last 365 days
|
||||
.mockResolvedValueOnce(50); // older than 1 year
|
||||
|
||||
mockGroupBy
|
||||
.mockResolvedValueOnce([
|
||||
{ eventType: "AUTHENTICATION", _count: { id: 600 } },
|
||||
{ eventType: "AUTHORIZATION", _count: { id: 400 } },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ severity: "INFO", _count: { id: 700 } },
|
||||
{ severity: "MEDIUM", _count: { id: 250 } },
|
||||
{ severity: "HIGH", _count: { id: 50 } },
|
||||
]);
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ timestamp: new Date("2023-01-01") })
|
||||
.mockResolvedValueOnce({ timestamp: new Date("2024-01-15") });
|
||||
|
||||
const manager = new AuditLogRetentionManager();
|
||||
const stats = await manager.getRetentionStatistics();
|
||||
|
||||
expect(stats.totalLogs).toBe(1000);
|
||||
expect(stats.logsByEventType).toEqual({
|
||||
AUTHENTICATION: 600,
|
||||
AUTHORIZATION: 400,
|
||||
});
|
||||
expect(stats.logsBySeverity).toEqual({
|
||||
INFO: 700,
|
||||
MEDIUM: 250,
|
||||
HIGH: 50,
|
||||
});
|
||||
expect(stats.logsByAge).toHaveLength(6);
|
||||
expect(stats.oldestLog).toEqual(new Date("2023-01-01"));
|
||||
expect(stats.newestLog).toEqual(new Date("2024-01-15"));
|
||||
});
|
||||
|
||||
it("should execute retention policies in dry run mode", async () => {
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
const mockDeleteMany = vi.mocked(prisma.securityAuditLog.deleteMany);
|
||||
|
||||
mockCount.mockResolvedValue(100);
|
||||
|
||||
const manager = new AuditLogRetentionManager(
|
||||
DEFAULT_RETENTION_POLICIES,
|
||||
true
|
||||
);
|
||||
const results = await manager.executeRetentionPolicies();
|
||||
|
||||
expect(results.totalProcessed).toBeGreaterThan(0);
|
||||
expect(mockDeleteMany).not.toHaveBeenCalled(); // Dry run shouldn't delete
|
||||
});
|
||||
|
||||
it("should execute retention policies with actual deletion", async () => {
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
const mockDeleteMany = vi.mocked(prisma.securityAuditLog.deleteMany);
|
||||
|
||||
mockCount.mockResolvedValue(50);
|
||||
mockDeleteMany.mockResolvedValue({ count: 50 });
|
||||
|
||||
const testPolicies = [
|
||||
{
|
||||
name: "Test Policy",
|
||||
maxAgeDays: 30,
|
||||
severityFilter: ["INFO"],
|
||||
archiveBeforeDelete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const manager = new AuditLogRetentionManager(testPolicies, false);
|
||||
const results = await manager.executeRetentionPolicies();
|
||||
|
||||
expect(results.totalDeleted).toBe(50);
|
||||
expect(mockDeleteMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle retention policy errors gracefully", async () => {
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
mockCount.mockRejectedValue(new Error("Database connection failed"));
|
||||
|
||||
const manager = new AuditLogRetentionManager();
|
||||
const results = await manager.executeRetentionPolicies();
|
||||
|
||||
expect(
|
||||
results.policyResults.every((result) => result.errors.length > 0)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect policy overlaps", async () => {
|
||||
const overlappingPolicies = [
|
||||
{
|
||||
name: "Policy 1",
|
||||
maxAgeDays: 30,
|
||||
severityFilter: ["INFO", "LOW"],
|
||||
},
|
||||
{
|
||||
name: "Policy 2",
|
||||
maxAgeDays: 60,
|
||||
severityFilter: ["LOW", "MEDIUM"],
|
||||
},
|
||||
];
|
||||
|
||||
const manager = new AuditLogRetentionManager(overlappingPolicies);
|
||||
const validation = await manager.validateRetentionPolicies();
|
||||
|
||||
expect(validation.warnings.some((w) => w.includes("overlap"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Severity Assignment", () => {
|
||||
it("should assign correct severity for user management actions", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockResolvedValue({} as any);
|
||||
|
||||
// Test privileged action
|
||||
await securityAuditLogger.logUserManagement(
|
||||
"user_deleted",
|
||||
AuditOutcome.SUCCESS,
|
||||
{ userId: "admin-123" }
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
severity: AuditSeverity.HIGH,
|
||||
}),
|
||||
});
|
||||
|
||||
mockCreate.mockClear();
|
||||
|
||||
// Test regular action
|
||||
await securityAuditLogger.logUserManagement(
|
||||
"user_profile_updated",
|
||||
AuditOutcome.SUCCESS,
|
||||
{ userId: "user-123" }
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
severity: AuditSeverity.MEDIUM,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign correct severity for company management actions", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockResolvedValue({} as any);
|
||||
|
||||
// Test critical action
|
||||
await securityAuditLogger.logCompanyManagement(
|
||||
"company_suspended",
|
||||
AuditOutcome.SUCCESS,
|
||||
{ platformUserId: "admin-123" }
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
severity: AuditSeverity.CRITICAL,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign high severity for data privacy events", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockResolvedValue({} as any);
|
||||
|
||||
await securityAuditLogger.logDataPrivacy(
|
||||
"data_exported",
|
||||
AuditOutcome.SUCCESS,
|
||||
{ userId: "user-123" }
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
severity: AuditSeverity.HIGH,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should continue operation when audit logging fails", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// This should not throw an error
|
||||
await expect(
|
||||
securityAuditLogger.logAuthentication(
|
||||
"test_action",
|
||||
AuditOutcome.SUCCESS,
|
||||
{}
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle missing context gracefully", async () => {
|
||||
const mockCreate = vi.mocked(prisma.securityAuditLog.create);
|
||||
mockCreate.mockResolvedValue({} as any);
|
||||
|
||||
await securityAuditLogger.logAuthentication(
|
||||
"test_action",
|
||||
AuditOutcome.SUCCESS,
|
||||
{} // Empty context
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: null,
|
||||
companyId: null,
|
||||
ipAddress: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
527
tests/unit/security-monitoring.test.ts
Normal file
527
tests/unit/security-monitoring.test.ts
Normal file
@ -0,0 +1,527 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import {
|
||||
securityMonitoring,
|
||||
enhancedSecurityLog,
|
||||
AlertSeverity,
|
||||
AlertType,
|
||||
ThreatLevel,
|
||||
} from "@/lib/securityMonitoring";
|
||||
import {
|
||||
SecurityEventType,
|
||||
AuditOutcome,
|
||||
AuditSeverity,
|
||||
} from "@/lib/securityAuditLogger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
securityAuditLog: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock securityAuditLogger
|
||||
vi.mock("@/lib/securityAuditLogger", async () => {
|
||||
const actual = await vi.importActual("@/lib/securityAuditLogger");
|
||||
return {
|
||||
...actual,
|
||||
securityAuditLogger: {
|
||||
log: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("Security Monitoring System", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset the monitoring service state
|
||||
securityMonitoring.updateConfig({
|
||||
thresholds: {
|
||||
failedLoginsPerMinute: 5,
|
||||
failedLoginsPerHour: 20,
|
||||
rateLimitViolationsPerMinute: 10,
|
||||
cspViolationsPerMinute: 15,
|
||||
adminActionsPerHour: 25,
|
||||
massDataAccessThreshold: 100,
|
||||
suspiciousIPThreshold: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("Alert Generation", () => {
|
||||
it("should generate brute force alert for multiple failed logins", async () => {
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
const mockFindMany = vi.mocked(prisma.securityAuditLog.findMany);
|
||||
|
||||
mockCount.mockResolvedValue(6); // Above threshold of 5
|
||||
mockFindMany.mockResolvedValue([]); // Empty historical events for anomaly detection
|
||||
|
||||
const context = {
|
||||
ipAddress: "192.168.1.100",
|
||||
userAgent: "Mozilla/5.0",
|
||||
requestId: "test-123",
|
||||
};
|
||||
|
||||
await enhancedSecurityLog(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
"login_attempt",
|
||||
AuditOutcome.FAILURE,
|
||||
context,
|
||||
AuditSeverity.HIGH,
|
||||
"Failed login attempt"
|
||||
);
|
||||
|
||||
expect(mockCount).toHaveBeenCalledWith({
|
||||
where: {
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
outcome: AuditOutcome.FAILURE,
|
||||
ipAddress: "192.168.1.100",
|
||||
timestamp: expect.any(Object),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate rate limit breach alert", async () => {
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
const mockFindMany = vi.mocked(prisma.securityAuditLog.findMany);
|
||||
|
||||
mockCount.mockResolvedValue(11); // Above threshold of 10
|
||||
mockFindMany.mockResolvedValue([]); // Empty historical events for anomaly detection
|
||||
|
||||
const context = {
|
||||
ipAddress: "192.168.1.100",
|
||||
userAgent: "Mozilla/5.0",
|
||||
requestId: "test-123",
|
||||
};
|
||||
|
||||
await enhancedSecurityLog(
|
||||
SecurityEventType.RATE_LIMITING,
|
||||
"rate_limit_exceeded",
|
||||
AuditOutcome.RATE_LIMITED,
|
||||
context,
|
||||
AuditSeverity.MEDIUM,
|
||||
"Rate limit exceeded"
|
||||
);
|
||||
|
||||
expect(mockCount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should generate admin activity alert for excessive actions", async () => {
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
const mockFindMany = vi.mocked(prisma.securityAuditLog.findMany);
|
||||
|
||||
mockCount.mockResolvedValue(26); // Above threshold of 25
|
||||
mockFindMany.mockResolvedValue([]); // Empty historical events for anomaly detection
|
||||
|
||||
const context = {
|
||||
userId: "user-123",
|
||||
requestId: "test-123",
|
||||
};
|
||||
|
||||
await enhancedSecurityLog(
|
||||
SecurityEventType.PLATFORM_ADMIN,
|
||||
"admin_action",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
AuditSeverity.INFO,
|
||||
"Admin action performed"
|
||||
);
|
||||
|
||||
expect(mockCount).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Anomaly Detection", () => {
|
||||
it("should detect geographical anomalies", async () => {
|
||||
const mockFindMany = vi.mocked(prisma.securityAuditLog.findMany);
|
||||
mockFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "1",
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
action: "login_success",
|
||||
outcome: AuditOutcome.SUCCESS,
|
||||
userId: "user-123",
|
||||
companyId: "company-1",
|
||||
platformUserId: null,
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
country: "USA",
|
||||
metadata: null,
|
||||
errorMessage: null,
|
||||
severity: AuditSeverity.INFO,
|
||||
sessionId: null,
|
||||
requestId: "req-1",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const context = {
|
||||
userId: "user-123",
|
||||
country: "CHN", // Different country
|
||||
requestId: "test-123",
|
||||
};
|
||||
|
||||
await enhancedSecurityLog(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
"login_success",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
AuditSeverity.INFO
|
||||
);
|
||||
|
||||
expect(mockFindMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should detect temporal anomalies", async () => {
|
||||
const mockFindMany = vi.mocked(prisma.securityAuditLog.findMany);
|
||||
|
||||
// Mock historical data showing low activity
|
||||
mockFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "1",
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
action: "login_success",
|
||||
outcome: AuditOutcome.SUCCESS,
|
||||
userId: "user-123",
|
||||
companyId: "company-1",
|
||||
platformUserId: null,
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
country: "USA",
|
||||
metadata: null,
|
||||
errorMessage: null,
|
||||
severity: AuditSeverity.INFO,
|
||||
sessionId: null,
|
||||
requestId: "req-1",
|
||||
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago
|
||||
},
|
||||
]);
|
||||
|
||||
// Simulate multiple events in short time
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await enhancedSecurityLog(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
"login_success",
|
||||
AuditOutcome.SUCCESS,
|
||||
{ requestId: `test-${i}` },
|
||||
AuditSeverity.INFO
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockFindMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Metrics", () => {
|
||||
it("should calculate comprehensive security metrics", async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: "1",
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
action: "login_success",
|
||||
outcome: AuditOutcome.SUCCESS,
|
||||
userId: "user-1",
|
||||
companyId: "company-1",
|
||||
platformUserId: null,
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
country: "USA",
|
||||
metadata: null,
|
||||
errorMessage: null,
|
||||
severity: AuditSeverity.INFO,
|
||||
sessionId: null,
|
||||
requestId: "req-1",
|
||||
timestamp: new Date(),
|
||||
user: { email: "user1@test.com" },
|
||||
company: { name: "Test Company" },
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
action: "login_failure",
|
||||
outcome: AuditOutcome.FAILURE,
|
||||
userId: "user-2",
|
||||
companyId: "company-1",
|
||||
platformUserId: null,
|
||||
ipAddress: "192.168.1.2",
|
||||
userAgent: "Mozilla/5.0",
|
||||
country: "GBR",
|
||||
metadata: null,
|
||||
errorMessage: "Invalid password",
|
||||
severity: AuditSeverity.CRITICAL,
|
||||
sessionId: null,
|
||||
requestId: "req-2",
|
||||
timestamp: new Date(),
|
||||
user: { email: "user2@test.com" },
|
||||
company: { name: "Test Company" },
|
||||
},
|
||||
];
|
||||
|
||||
const mockFindMany = vi.mocked(prisma.securityAuditLog.findMany);
|
||||
mockFindMany.mockResolvedValue(mockEvents);
|
||||
|
||||
const timeRange = {
|
||||
start: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(),
|
||||
};
|
||||
|
||||
const metrics = await securityMonitoring.getSecurityMetrics(timeRange);
|
||||
|
||||
expect(metrics).toMatchObject({
|
||||
totalEvents: 2,
|
||||
criticalEvents: 1,
|
||||
activeAlerts: expect.any(Number),
|
||||
resolvedAlerts: expect.any(Number),
|
||||
securityScore: expect.any(Number),
|
||||
threatLevel: expect.any(String),
|
||||
eventsByType: expect.any(Object),
|
||||
alertsByType: expect.any(Object),
|
||||
topThreats: expect.any(Array),
|
||||
geoDistribution: expect.any(Object),
|
||||
timeDistribution: expect.any(Array),
|
||||
userRiskScores: expect.any(Array),
|
||||
});
|
||||
|
||||
expect(metrics.securityScore).toBeGreaterThanOrEqual(0);
|
||||
expect(metrics.securityScore).toBeLessThanOrEqual(100);
|
||||
expect(Object.values(ThreatLevel)).toContain(metrics.threatLevel);
|
||||
});
|
||||
|
||||
it("should calculate user risk scores correctly", async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: "1",
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
action: "login_failure",
|
||||
outcome: AuditOutcome.FAILURE,
|
||||
userId: "user-1",
|
||||
companyId: "company-1",
|
||||
platformUserId: null,
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
country: "USA",
|
||||
metadata: null,
|
||||
errorMessage: "Invalid password",
|
||||
severity: AuditSeverity.HIGH,
|
||||
sessionId: null,
|
||||
requestId: "req-1",
|
||||
timestamp: new Date(),
|
||||
user: { email: "highrisk@test.com" },
|
||||
company: { name: "Test Company" },
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
eventType: SecurityEventType.RATE_LIMITING,
|
||||
action: "rate_limit_exceeded",
|
||||
outcome: AuditOutcome.RATE_LIMITED,
|
||||
userId: "user-1",
|
||||
companyId: "company-1",
|
||||
platformUserId: null,
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
country: "USA",
|
||||
metadata: null,
|
||||
errorMessage: null,
|
||||
severity: AuditSeverity.MEDIUM,
|
||||
sessionId: null,
|
||||
requestId: "req-2",
|
||||
timestamp: new Date(),
|
||||
user: { email: "highrisk@test.com" },
|
||||
company: { name: "Test Company" },
|
||||
},
|
||||
];
|
||||
|
||||
const mockFindMany = vi.mocked(prisma.securityAuditLog.findMany);
|
||||
mockFindMany.mockResolvedValue(mockEvents);
|
||||
|
||||
const timeRange = {
|
||||
start: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(),
|
||||
};
|
||||
|
||||
const metrics = await securityMonitoring.getSecurityMetrics(timeRange);
|
||||
|
||||
expect(metrics.userRiskScores).toHaveLength(1);
|
||||
expect(metrics.userRiskScores[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
email: "highrisk@test.com",
|
||||
riskScore: expect.any(Number),
|
||||
});
|
||||
expect(metrics.userRiskScores[0].riskScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IP Threat Analysis", () => {
|
||||
it("should calculate IP threat level correctly", async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
outcome: AuditOutcome.FAILURE,
|
||||
userId: "user-1",
|
||||
ipAddress: "192.168.1.100",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
eventType: SecurityEventType.RATE_LIMITING,
|
||||
outcome: AuditOutcome.RATE_LIMITED,
|
||||
userId: "user-2",
|
||||
ipAddress: "192.168.1.100",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const mockFindMany = vi.mocked(prisma.securityAuditLog.findMany);
|
||||
mockFindMany.mockResolvedValue(mockEvents);
|
||||
|
||||
const analysis =
|
||||
await securityMonitoring.calculateIPThreatLevel("192.168.1.100");
|
||||
|
||||
expect(analysis).toMatchObject({
|
||||
threatLevel: expect.any(String),
|
||||
riskFactors: expect.any(Array),
|
||||
recommendations: expect.any(Array),
|
||||
});
|
||||
|
||||
expect(Object.values(ThreatLevel)).toContain(analysis.threatLevel);
|
||||
expect(analysis.riskFactors.length).toBeGreaterThan(0);
|
||||
expect(analysis.recommendations.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Configuration Management", () => {
|
||||
it("should update monitoring configuration", () => {
|
||||
const newConfig = {
|
||||
thresholds: {
|
||||
failedLoginsPerMinute: 3,
|
||||
failedLoginsPerHour: 15,
|
||||
},
|
||||
alerting: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
securityMonitoring.updateConfig(newConfig);
|
||||
const currentConfig = securityMonitoring.getConfig();
|
||||
|
||||
expect(currentConfig.thresholds.failedLoginsPerMinute).toBe(3);
|
||||
expect(currentConfig.thresholds.failedLoginsPerHour).toBe(15);
|
||||
expect(currentConfig.alerting.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should preserve existing config when partially updating", () => {
|
||||
const originalConfig = securityMonitoring.getConfig();
|
||||
|
||||
securityMonitoring.updateConfig({
|
||||
thresholds: {
|
||||
failedLoginsPerMinute: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedConfig = securityMonitoring.getConfig();
|
||||
|
||||
expect(updatedConfig.thresholds.failedLoginsPerMinute).toBe(2);
|
||||
expect(updatedConfig.thresholds.failedLoginsPerHour).toBe(
|
||||
originalConfig.thresholds.failedLoginsPerHour
|
||||
);
|
||||
expect(updatedConfig.alerting.enabled).toBe(
|
||||
originalConfig.alerting.enabled
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Alert Management", () => {
|
||||
it("should acknowledge alerts correctly", async () => {
|
||||
// First, generate an alert
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
mockCount.mockResolvedValue(6); // Above threshold
|
||||
|
||||
await enhancedSecurityLog(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
"login_attempt",
|
||||
AuditOutcome.FAILURE,
|
||||
{ ipAddress: "192.168.1.100" },
|
||||
AuditSeverity.HIGH
|
||||
);
|
||||
|
||||
const activeAlerts = securityMonitoring.getActiveAlerts();
|
||||
expect(activeAlerts.length).toBeGreaterThan(0);
|
||||
|
||||
const alertId = activeAlerts[0].id;
|
||||
const acknowledged = await securityMonitoring.acknowledgeAlert(
|
||||
alertId,
|
||||
"admin-user"
|
||||
);
|
||||
|
||||
expect(acknowledged).toBe(true);
|
||||
|
||||
const remainingActiveAlerts = securityMonitoring.getActiveAlerts();
|
||||
expect(remainingActiveAlerts.length).toBe(activeAlerts.length - 1);
|
||||
});
|
||||
|
||||
it("should filter alerts by severity", async () => {
|
||||
// Generate alerts of different severities
|
||||
const mockCount = vi.mocked(prisma.securityAuditLog.count);
|
||||
mockCount.mockResolvedValue(6);
|
||||
|
||||
await enhancedSecurityLog(
|
||||
SecurityEventType.AUTHENTICATION,
|
||||
"login_attempt",
|
||||
AuditOutcome.FAILURE,
|
||||
{ ipAddress: "192.168.1.100" },
|
||||
AuditSeverity.HIGH
|
||||
);
|
||||
|
||||
await enhancedSecurityLog(
|
||||
SecurityEventType.RATE_LIMITING,
|
||||
"rate_limit",
|
||||
AuditOutcome.RATE_LIMITED,
|
||||
{ ipAddress: "192.168.1.101" },
|
||||
AuditSeverity.MEDIUM
|
||||
);
|
||||
|
||||
const highSeverityAlerts = securityMonitoring.getActiveAlerts(
|
||||
AlertSeverity.HIGH
|
||||
);
|
||||
const allAlerts = securityMonitoring.getActiveAlerts();
|
||||
|
||||
expect(highSeverityAlerts.length).toBeLessThanOrEqual(allAlerts.length);
|
||||
highSeverityAlerts.forEach((alert) => {
|
||||
expect(alert.severity).toBe(AlertSeverity.HIGH);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Export", () => {
|
||||
it("should export security data in JSON format", () => {
|
||||
const timeRange = {
|
||||
start: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(),
|
||||
};
|
||||
|
||||
const jsonData = securityMonitoring.exportSecurityData("json", timeRange);
|
||||
|
||||
expect(() => JSON.parse(jsonData)).not.toThrow();
|
||||
const parsed = JSON.parse(jsonData);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
|
||||
it("should export security data in CSV format", () => {
|
||||
const timeRange = {
|
||||
start: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(),
|
||||
};
|
||||
|
||||
const csvData = securityMonitoring.exportSecurityData("csv", timeRange);
|
||||
|
||||
expect(typeof csvData).toBe("string");
|
||||
expect(csvData).toContain("timestamp,severity,type,title");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user