407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
/**
|
|
* Audit Logging Tests
|
|
* Tests for audit logging system including log creation, querying, and analysis
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from "bun:test";
|
|
import {
|
|
logAuditEvent,
|
|
queryAuditLogs,
|
|
getFailedLoginAttempts,
|
|
getUserSecuritySummary,
|
|
detectSuspiciousActivity,
|
|
cleanupOldLogs
|
|
} from "~/server/audit";
|
|
import { ConnectionFactory } from "~/server/database";
|
|
|
|
// Helper to clean up test audit logs
|
|
async function cleanupTestLogs() {
|
|
const conn = ConnectionFactory();
|
|
await conn.execute({
|
|
sql: "DELETE FROM AuditLog WHERE event_data LIKE '%test-%'"
|
|
});
|
|
}
|
|
|
|
describe("Audit Logging System", () => {
|
|
beforeEach(async () => {
|
|
await cleanupTestLogs();
|
|
});
|
|
|
|
describe("logAuditEvent", () => {
|
|
it("should create audit log with all fields", async () => {
|
|
await logAuditEvent({
|
|
eventType: "auth.login.success",
|
|
eventData: { method: "password", test: "test-1" },
|
|
ipAddress: "192.168.1.100",
|
|
userAgent: "Test Browser 1.0",
|
|
success: true
|
|
});
|
|
|
|
const logs = await queryAuditLogs({
|
|
ipAddress: "192.168.1.100",
|
|
limit: 1
|
|
});
|
|
|
|
expect(logs.length).toBe(1);
|
|
expect(logs[0].userId).toBeNull();
|
|
expect(logs[0].eventType).toBe("auth.login.success");
|
|
expect(logs[0].ipAddress).toBe("192.168.1.100");
|
|
expect(logs[0].userAgent).toBe("Test Browser 1.0");
|
|
expect(logs[0].success).toBe(true);
|
|
|
|
expect(logs[0].eventData.method).toBe("password");
|
|
});
|
|
|
|
it("should create audit log without user ID", async () => {
|
|
await logAuditEvent({
|
|
eventType: "auth.login.failed",
|
|
eventData: { email: "test@example.com", test: "test-2" },
|
|
ipAddress: "192.168.1.2",
|
|
userAgent: "Test Browser 1.0",
|
|
success: false
|
|
});
|
|
|
|
const logs = await queryAuditLogs({
|
|
eventType: "auth.login.failed",
|
|
limit: 1
|
|
});
|
|
|
|
expect(logs.length).toBe(1);
|
|
expect(logs[0].userId).toBeNull();
|
|
expect(logs[0].success).toBe(false);
|
|
});
|
|
|
|
it("should handle missing optional fields gracefully", async () => {
|
|
await logAuditEvent({
|
|
eventType: "auth.logout",
|
|
success: true
|
|
});
|
|
|
|
const logs = await queryAuditLogs({
|
|
eventType: "auth.logout",
|
|
limit: 1
|
|
});
|
|
|
|
expect(logs.length).toBe(1);
|
|
expect(logs[0].ipAddress).toBeNull();
|
|
expect(logs[0].userAgent).toBeNull();
|
|
expect(logs[0].eventData).toBeNull();
|
|
});
|
|
|
|
it("should not throw errors on logging failures", async () => {
|
|
// This should not throw even if there's an invalid event type
|
|
await expect(
|
|
logAuditEvent({
|
|
eventType: "invalid.test.event",
|
|
success: true
|
|
})
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("queryAuditLogs", () => {
|
|
beforeEach(async () => {
|
|
// Create test logs
|
|
await logAuditEvent({
|
|
eventType: "auth.login.success",
|
|
eventData: { test: "test-query-1", testUser: "user-1" },
|
|
ipAddress: "192.168.1.10",
|
|
success: true
|
|
});
|
|
|
|
await logAuditEvent({
|
|
eventType: "auth.login.failed",
|
|
eventData: { test: "test-query-2", testUser: "user-1" },
|
|
ipAddress: "192.168.1.10",
|
|
success: false
|
|
});
|
|
|
|
await logAuditEvent({
|
|
eventType: "auth.login.success",
|
|
eventData: { test: "test-query-3", testUser: "user-2" },
|
|
ipAddress: "192.168.1.20",
|
|
success: true
|
|
});
|
|
|
|
await logAuditEvent({
|
|
eventType: "auth.password_reset.requested",
|
|
eventData: { test: "test-query-4", testUser: "user-2" },
|
|
ipAddress: "192.168.1.20",
|
|
success: true
|
|
});
|
|
});
|
|
|
|
it("should query logs by IP address (simulating user)", async () => {
|
|
const logs = await queryAuditLogs({ ipAddress: "192.168.1.10" });
|
|
|
|
expect(logs.length).toBeGreaterThanOrEqual(2);
|
|
expect(logs.every((log) => log.ipAddress === "192.168.1.10")).toBe(true);
|
|
});
|
|
|
|
it("should query logs by event type", async () => {
|
|
const logs = await queryAuditLogs({
|
|
eventType: "auth.login.success"
|
|
});
|
|
|
|
expect(logs.length).toBeGreaterThanOrEqual(2);
|
|
expect(logs.every((log) => log.eventType === "auth.login.success")).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it("should query logs by success status", async () => {
|
|
const successLogs = await queryAuditLogs({ success: true });
|
|
const failedLogs = await queryAuditLogs({ success: false });
|
|
|
|
expect(successLogs.length).toBeGreaterThanOrEqual(3);
|
|
expect(failedLogs.length).toBeGreaterThanOrEqual(1);
|
|
expect(successLogs.every((log) => log.success === true)).toBe(true);
|
|
expect(failedLogs.every((log) => log.success === false)).toBe(true);
|
|
});
|
|
|
|
it("should respect limit parameter", async () => {
|
|
const logs = await queryAuditLogs({ limit: 2 });
|
|
|
|
expect(logs.length).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it("should respect offset parameter", async () => {
|
|
const firstPage = await queryAuditLogs({ limit: 2, offset: 0 });
|
|
const secondPage = await queryAuditLogs({ limit: 2, offset: 2 });
|
|
|
|
expect(firstPage.length).toBeGreaterThan(0);
|
|
if (secondPage.length > 0) {
|
|
expect(firstPage[0].id).not.toBe(secondPage[0].id);
|
|
}
|
|
});
|
|
|
|
it("should filter by date range", async () => {
|
|
const now = new Date();
|
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
|
const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
|
|
const logs = await queryAuditLogs({
|
|
startDate: oneHourAgo.toISOString(),
|
|
endDate: oneDayFromNow.toISOString()
|
|
});
|
|
|
|
expect(logs.length).toBeGreaterThanOrEqual(4);
|
|
});
|
|
|
|
it("should return empty array when no logs match", async () => {
|
|
const logs = await queryAuditLogs({
|
|
userId: "nonexistent-user-xyz"
|
|
});
|
|
|
|
expect(logs).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("getFailedLoginAttempts", () => {
|
|
beforeEach(async () => {
|
|
// Create failed login attempts
|
|
for (let i = 0; i < 5; i++) {
|
|
await logAuditEvent({
|
|
eventType: "auth.login.failed",
|
|
eventData: {
|
|
email: `test${i}@example.com`,
|
|
test: `test-failed-${i}`
|
|
},
|
|
ipAddress: `192.168.1.${i}`,
|
|
success: false
|
|
});
|
|
}
|
|
|
|
// Create successful logins (should be excluded)
|
|
await logAuditEvent({
|
|
eventType: "auth.login.success",
|
|
eventData: { test: "test-success-1" },
|
|
success: true
|
|
});
|
|
});
|
|
|
|
it("should return only failed login attempts", async () => {
|
|
const attempts = await getFailedLoginAttempts(24, 10);
|
|
|
|
expect(attempts.length).toBeGreaterThanOrEqual(5);
|
|
expect(
|
|
attempts.every((attempt) => attempt.event_type === "auth.login.failed")
|
|
).toBe(true);
|
|
expect(attempts.every((attempt) => attempt.success === 0)).toBe(true);
|
|
});
|
|
|
|
it("should respect limit parameter", async () => {
|
|
const attempts = await getFailedLoginAttempts(24, 3);
|
|
|
|
expect(attempts.length).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
it("should filter by time window", async () => {
|
|
const attemptsIn24h = await getFailedLoginAttempts(24, 100);
|
|
const attemptsIn1h = await getFailedLoginAttempts(1, 100);
|
|
|
|
expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5);
|
|
// Recent attempts should be within 1 hour
|
|
expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5);
|
|
});
|
|
});
|
|
|
|
describe("getUserSecuritySummary", () => {
|
|
beforeEach(async () => {
|
|
// Create various events for summary (without user IDs due to FK constraint)
|
|
await logAuditEvent({
|
|
eventType: "auth.login.success",
|
|
eventData: { test: "test-summary-1", testSummaryUser: "summary-123" },
|
|
ipAddress: "192.168.99.1",
|
|
success: true
|
|
});
|
|
|
|
await logAuditEvent({
|
|
eventType: "auth.login.failed",
|
|
eventData: { test: "test-summary-2", testSummaryUser: "summary-123" },
|
|
ipAddress: "192.168.99.1",
|
|
success: false
|
|
});
|
|
|
|
await logAuditEvent({
|
|
eventType: "auth.login.failed",
|
|
eventData: { test: "test-summary-3", testSummaryUser: "summary-123" },
|
|
ipAddress: "192.168.99.1",
|
|
success: false
|
|
});
|
|
|
|
await logAuditEvent({
|
|
eventType: "auth.password_reset.requested",
|
|
eventData: { test: "test-summary-4", testSummaryUser: "summary-123" },
|
|
ipAddress: "192.168.99.1",
|
|
success: true
|
|
});
|
|
});
|
|
|
|
it("should return zero counts for non-existent user", async () => {
|
|
// Since we can't use real user IDs in tests, this test verifies query works
|
|
const summary = await getUserSecuritySummary("nonexistent-user", 30);
|
|
|
|
expect(summary).toHaveProperty("totalEvents");
|
|
expect(summary).toHaveProperty("successfulEvents");
|
|
expect(summary).toHaveProperty("failedEvents");
|
|
expect(summary).toHaveProperty("eventTypes");
|
|
expect(summary).toHaveProperty("uniqueIPs");
|
|
});
|
|
|
|
it("should return summary structure", async () => {
|
|
const summary = await getUserSecuritySummary("nonexistent-user", 30);
|
|
|
|
expect(summary.totalEvents).toBe(0);
|
|
expect(summary.successfulEvents).toBe(0);
|
|
expect(summary.failedEvents).toBe(0);
|
|
expect(summary.eventTypes.length).toBe(0);
|
|
expect(summary.uniqueIPs.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("detectSuspiciousActivity", () => {
|
|
beforeEach(async () => {
|
|
// Create suspicious pattern: many failed logins from same IP
|
|
for (let i = 0; i < 10; i++) {
|
|
await logAuditEvent({
|
|
eventType: "auth.login.failed",
|
|
eventData: {
|
|
email: `victim${i}@example.com`,
|
|
test: `test-suspicious-${i}`
|
|
},
|
|
ipAddress: "10.0.0.1",
|
|
success: false
|
|
});
|
|
}
|
|
|
|
// Create normal activity
|
|
await logAuditEvent({
|
|
eventType: "auth.login.success",
|
|
eventData: { test: "test-normal-1" },
|
|
ipAddress: "10.0.0.2",
|
|
success: true
|
|
});
|
|
});
|
|
|
|
it("should detect IPs with excessive failed attempts", async () => {
|
|
const suspicious = await detectSuspiciousActivity(24, 5);
|
|
|
|
expect(suspicious.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const suspiciousIP = suspicious.find((s) => s.ipAddress === "10.0.0.1");
|
|
expect(suspiciousIP).toBeDefined();
|
|
expect(suspiciousIP!.failedAttempts).toBeGreaterThanOrEqual(10);
|
|
});
|
|
|
|
it("should respect minimum attempts threshold", async () => {
|
|
const lowThreshold = await detectSuspiciousActivity(24, 5);
|
|
const highThreshold = await detectSuspiciousActivity(24, 20);
|
|
|
|
expect(lowThreshold.length).toBeGreaterThanOrEqual(highThreshold.length);
|
|
});
|
|
|
|
it("should return empty array when no suspicious activity", async () => {
|
|
await cleanupTestLogs();
|
|
|
|
// Create only successful logins
|
|
await logAuditEvent({
|
|
eventType: "auth.login.success",
|
|
eventData: { test: "test-clean-1" },
|
|
ipAddress: "10.0.0.100",
|
|
success: true
|
|
});
|
|
|
|
const suspicious = await detectSuspiciousActivity(24, 5);
|
|
const cleanIP = suspicious.find((s) => s.ipAddress === "10.0.0.100");
|
|
|
|
expect(cleanIP).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("cleanupOldLogs", () => {
|
|
it("should delete logs older than specified days", async () => {
|
|
// Create an old log by directly inserting with past date
|
|
const conn = ConnectionFactory();
|
|
const veryOldDate = new Date();
|
|
veryOldDate.setDate(veryOldDate.getDate() - 100); // 100 days ago
|
|
|
|
await conn.execute({
|
|
sql: `INSERT INTO AuditLog (id, event_type, event_data, success, created_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
args: [
|
|
`old-log-${Date.now()}`,
|
|
"auth.login.success",
|
|
JSON.stringify({ test: "test-cleanup-1" }),
|
|
1,
|
|
veryOldDate.toISOString()
|
|
]
|
|
});
|
|
|
|
// Clean up logs older than 90 days
|
|
const deleted = await cleanupOldLogs(90);
|
|
|
|
expect(deleted).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("should not delete recent logs", async () => {
|
|
await logAuditEvent({
|
|
eventType: "auth.login.success",
|
|
eventData: { test: "test-recent-1" },
|
|
success: true
|
|
});
|
|
|
|
const logsBefore = await queryAuditLogs({ limit: 100 });
|
|
const countBefore = logsBefore.length;
|
|
|
|
// Try to clean up logs older than 1 day (should not delete recent log)
|
|
await cleanupOldLogs(1);
|
|
|
|
const logsAfter = await queryAuditLogs({ limit: 100 });
|
|
|
|
// Should still have recent logs
|
|
expect(logsAfter.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|