mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 18:12:08 +01:00
- 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
554 lines
16 KiB
TypeScript
554 lines
16 KiB
TypeScript
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,
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
});
|