Files
formbricks/apps/web/modules/auth/lib/utils.test.ts
victorvhs017 a9946737df feat: audit logs (#5866)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-06-05 19:31:39 +00:00

402 lines
13 KiB
TypeScript

import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
createAuditIdentifier,
hashPassword,
logAuthAttempt,
logAuthEvent,
logAuthSuccess,
logEmailVerificationAttempt,
logSignOut,
logTwoFactorAttempt,
shouldLogAuthFailure,
verifyPassword,
} from "./utils";
// Mock the audit event handler
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
// Mock crypto for consistent hash testing
vi.mock("crypto", () => ({
createHash: vi.fn(() => ({
update: vi.fn(() => ({
digest: vi.fn(() => "a".repeat(32)), // Mock 64-char hex string
})),
})),
}));
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "test-sentry-dsn",
IS_PRODUCTION: true,
REDIS_URL: "redis://localhost:6379",
}));
// Mock Redis client
vi.mock("@/modules/cache/redis", () => ({
default: null, // Intentionally simulate Redis unavailability to test fail-closed security behavior
}));
describe("Auth Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllTimers();
});
describe("Password Utils", () => {
const password = "password";
const hashedPassword = "$2a$12$LZsLq.9nkZlU0YDPx2aLNelnwD/nyavqbewLN.5.Q5h/UxRD8Ymcy";
test("should hash a password", async () => {
const newHashedPassword = await hashPassword(password);
expect(typeof newHashedPassword).toBe("string");
expect(newHashedPassword).not.toBe(password);
expect(newHashedPassword.length).toBe(60);
});
test("should verify a correct password", async () => {
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
test("should reject an incorrect password", async () => {
const isValid = await verifyPassword("WrongPassword123!", hashedPassword);
expect(isValid).toBe(false);
});
});
describe("Audit Identifier Utils", () => {
test("should create a hashed identifier for email", () => {
const email = "user@example.com";
const identifier = createAuditIdentifier(email, "email");
expect(identifier).toBe("email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
expect(identifier).not.toContain("user@example.com");
});
test("should return unknown for empty/unknown identifiers", () => {
expect(createAuditIdentifier("")).toBe("unknown");
expect(createAuditIdentifier("unknown")).toBe("unknown");
expect(createAuditIdentifier("unknown_user")).toBe("unknown");
});
test("should create consistent hashes for same input", () => {
const email = "test@example.com";
const id1 = createAuditIdentifier(email, "email");
const id2 = createAuditIdentifier(email, "email");
expect(id1).toBe(id2);
});
test("should use default prefix when none provided", () => {
const identifier = createAuditIdentifier("test@example.com");
expect(identifier).toMatch(/^actor_/);
});
});
describe("Rate Limiting", () => {
test("should always allow successful authentication logging", async () => {
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
});
test("should implement fail-closed behavior when Redis is unavailable", async () => {
const email = "rate-limit-test@example.com";
// When Redis is unavailable (mocked as null), the system fails closed for security.
// This prevents authentication failure logging when we cannot enforce rate limiting,
// ensuring consistent security posture across distributed systems.
// All authentication failure attempts should return false (do not log).
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 1st failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 2nd failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 3rd failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 4th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 5th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 6th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 7th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 8th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 9th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 10th failure - blocked
});
});
describe("Audit Logging Functions", () => {
test("should log auth event with hashed identifier", () => {
logAuthEvent("authenticationAttempted", "failure", "unknown", "user@example.com", {
failureReason: "invalid_password",
});
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "authenticationAttempted",
targetType: "user",
userId: "email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
targetId: "email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
organizationId: "unknown",
status: "failure",
userType: "user",
newObject: {
failureReason: "invalid_password",
},
});
});
test("should use provided userId when available", () => {
logAuthEvent("passwordVerified", "success", "user_123", "user@example.com", {
requires2FA: true,
});
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "passwordVerified",
targetType: "user",
userId: "user_123",
targetId: "user_123",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: {
requires2FA: true,
},
});
});
test("should log authentication attempt with correct structure", () => {
logAuthAttempt(
"invalid_password",
"credentials",
"password_validation",
"user_123",
"user@example.com"
);
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "authenticationAttempted",
status: "failure",
userId: "user_123",
newObject: expect.objectContaining({
failureReason: "invalid_password",
provider: "credentials",
authMethod: "password_validation",
}),
})
);
});
test("should log successful authentication", () => {
logAuthSuccess(
"authenticationSucceeded",
"credentials",
"password_only",
"user_123",
"user@example.com"
);
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "authenticationSucceeded",
status: "success",
userId: "user_123",
newObject: expect.objectContaining({
provider: "credentials",
authMethod: "password_only",
}),
})
);
});
test("should log two-factor verification", () => {
logTwoFactorAttempt(true, "totp", "user_123", "user@example.com");
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "twoFactorVerified",
status: "success",
newObject: expect.objectContaining({
provider: "credentials",
authMethod: "totp",
}),
})
);
});
test("should log failed two-factor attempt", () => {
logTwoFactorAttempt(false, "backup_code", "user_123", "user@example.com", "invalid_backup_code");
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "twoFactorAttempted",
status: "failure",
newObject: expect.objectContaining({
provider: "credentials",
authMethod: "backup_code",
failureReason: "invalid_backup_code",
}),
})
);
});
test("should log email verification", () => {
logEmailVerificationAttempt(true, undefined, "user_123", "user@example.com", {
emailVerifiedAt: new Date().toISOString(),
});
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "emailVerified",
status: "success",
newObject: expect.objectContaining({
provider: "token",
authMethod: "email_verification",
}),
})
);
});
test("should log failed email verification", () => {
logEmailVerificationAttempt(false, "invalid_token", "user_123", "user@example.com", {
tokenProvided: true,
});
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "emailVerificationAttempted",
targetType: "user",
userId: "user_123",
userType: "user",
targetId: "user_123",
organizationId: UNKNOWN_DATA,
status: "failure",
newObject: {
failureReason: "invalid_token",
provider: "token",
authMethod: "email_verification",
tokenProvided: true,
},
});
});
test("should log user sign out event", () => {
logSignOut("user_123", "user@example.com", {
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org_123",
});
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "userSignedOut",
targetType: "user",
userId: "user_123",
userType: "user",
targetId: "user_123",
organizationId: UNKNOWN_DATA,
status: "success",
newObject: {
provider: "session",
authMethod: "sign_out",
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org_123",
},
});
});
test("should log sign out with default reason", () => {
logSignOut("user_123", "user@example.com");
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "userSignedOut",
targetType: "user",
userId: "user_123",
userType: "user",
targetId: "user_123",
organizationId: UNKNOWN_DATA,
status: "success",
newObject: {
provider: "session",
authMethod: "sign_out",
reason: "user_initiated",
organizationId: undefined,
redirectUrl: undefined,
},
});
});
});
describe("PII Protection", () => {
test("should never log actual email addresses", () => {
const email = "sensitive@company.com";
logAuthAttempt("invalid_password", "credentials", "password_validation", "unknown", email);
const logCall = (queueAuditEventBackground as any).mock.calls[0][0];
const logString = JSON.stringify(logCall);
expect(logString).not.toContain("sensitive@company.com");
expect(logString).not.toContain("company.com");
expect(logString).not.toContain("sensitive");
});
test("should create consistent hashed identifiers", () => {
const email = "user@example.com";
logAuthAttempt("invalid_password", "credentials", "password_validation", "unknown", email);
logAuthAttempt("user_not_found", "credentials", "user_lookup", "unknown", email);
const calls = (queueAuditEventBackground as any).mock.calls;
expect(calls[0][0].userId).toBe(calls[1][0].userId);
});
});
describe("Sentry Integration", () => {
test("should capture authentication failures to Sentry", () => {
logAuthEvent("authenticationAttempted", "failure", "user_123", "user@example.com", {
failureReason: "invalid_password",
provider: "credentials",
authMethod: "password_validation",
tags: { security_event: "password_failure" },
});
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
tags: expect.objectContaining({
component: "authentication",
action: "authenticationAttempted",
status: "failure",
security_event: "password_failure",
}),
extra: expect.objectContaining({
userId: "user_123",
provider: "credentials",
authMethod: "password_validation",
failureReason: "invalid_password",
}),
})
);
});
test("should not capture successful authentication to Sentry", () => {
vi.clearAllMocks();
logAuthEvent("passwordVerified", "success", "user_123", "user@example.com", {
provider: "credentials",
authMethod: "password_validation",
});
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
});