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:
2025-07-11 21:50:53 +02:00
committed by Kaj Kowalski
parent 3e9e75e854
commit 1eea2cc3e4
121 changed files with 28687 additions and 4895 deletions

View File

@ -321,4 +321,4 @@ describe("CSRF Hooks", () => {
);
});
});
});
});

View File

@ -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");
});
});
});
});

View 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);
});
});
});

View File

@ -370,4 +370,4 @@ describe("Security Header Integration", () => {
expect(cspValue).toContain("style-src 'self' 'unsafe-inline'");
});
});
});
});

View File

@ -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
});
});
});
});

View 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,
}),
});
});
});
});

View 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");
});
});
});