mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 22:32:09 +01:00
refactor: fix biome linting issues and update project documentation
- Fix 36+ biome linting issues reducing errors/warnings from 227 to 191 - Replace explicit 'any' types with proper TypeScript interfaces - Fix React hooks dependencies and useCallback patterns - Resolve unused variables and parameter assignment issues - Improve accessibility with proper label associations - Add comprehensive API documentation for admin and security features - Update README.md with accurate PostgreSQL setup and current tech stack - Create complete documentation for audit logging, CSP monitoring, and batch processing - Fix outdated project information and missing developer workflows
This commit is contained in:
@ -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