mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
860 lines
30 KiB
TypeScript
860 lines
30 KiB
TypeScript
import * as Sentry from "@sentry/nextjs";
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
|
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
|
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
|
|
})),
|
|
})),
|
|
randomUUID: vi.fn(() => "test-uuid-123"),
|
|
}));
|
|
|
|
// 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",
|
|
ENCRYPTION_KEY: "test-encryption-key",
|
|
}));
|
|
|
|
// Mock cache module
|
|
const { mockCache, mockLogger } = vi.hoisted(() => ({
|
|
mockCache: {
|
|
getRedisClient: vi.fn(),
|
|
},
|
|
mockLogger: {
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/cache", () => ({
|
|
cache: mockCache,
|
|
}));
|
|
|
|
vi.mock("@formbricks/logger", () => ({
|
|
logger: mockLogger,
|
|
}));
|
|
|
|
// Mock @formbricks/cache
|
|
vi.mock("@formbricks/cache", () => ({
|
|
createCacheKey: {
|
|
custom: vi.fn((namespace: string, ...parts: string[]) => `${namespace}:${parts.join(":")}`),
|
|
rateLimit: {
|
|
core: vi.fn(
|
|
(namespace: string, identifier: string, bucketStart: number) =>
|
|
`rate_limit:${namespace}:${identifier}:${bucketStart}`
|
|
),
|
|
},
|
|
},
|
|
}));
|
|
|
|
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);
|
|
});
|
|
|
|
test("should handle empty password correctly", async () => {
|
|
const isValid = await verifyPassword("", hashedPassword);
|
|
expect(isValid).toBe(false);
|
|
});
|
|
|
|
test("should handle empty hash correctly", async () => {
|
|
const isValid = await verifyPassword(password, "");
|
|
expect(isValid).toBe(false);
|
|
});
|
|
|
|
test("should generate different hashes for same password", async () => {
|
|
const hash1 = await hashPassword(password);
|
|
const hash2 = await hashPassword(password);
|
|
|
|
expect(hash1).not.toBe(hash2);
|
|
expect(await verifyPassword(password, hash1)).toBe(true);
|
|
expect(await verifyPassword(password, hash2)).toBe(true);
|
|
});
|
|
|
|
test("should hash complex passwords correctly", async () => {
|
|
const complexPassword = "MyC0mpl3x!P@ssw0rd#2024$%^&*()";
|
|
const hashedComplex = await hashPassword(complexPassword);
|
|
|
|
expect(typeof hashedComplex).toBe("string");
|
|
expect(hashedComplex.length).toBe(60);
|
|
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
|
|
expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
|
|
});
|
|
|
|
test("should handle bcrypt errors gracefully and log warning", async () => {
|
|
// Save the original bcryptjs implementation
|
|
const originalModule = await import("bcryptjs");
|
|
|
|
// Mock bcryptjs to throw an error on compare
|
|
vi.doMock("bcryptjs", () => ({
|
|
...originalModule,
|
|
compare: vi.fn().mockRejectedValue(new Error("Invalid salt version")),
|
|
hash: originalModule.hash, // Keep hash working
|
|
}));
|
|
|
|
// Re-import the utils module to use the mocked bcryptjs
|
|
const { verifyPassword: verifyPasswordMocked } = await import("./utils?t=" + Date.now());
|
|
|
|
const password = "testPassword";
|
|
const invalidHash = "invalid-hash-format";
|
|
|
|
const result = await verifyPasswordMocked(password, invalidHash);
|
|
|
|
// Should return false for security
|
|
expect(result).toBe(false);
|
|
|
|
// Should log warning with correct signature (Pino format: object first, then message)
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
{ error: expect.any(Error) },
|
|
"Secret verification failed due to invalid hash format"
|
|
);
|
|
|
|
// Restore the module
|
|
vi.doUnmock("bcryptjs");
|
|
});
|
|
});
|
|
|
|
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_/);
|
|
});
|
|
|
|
test("should handle case-insensitive inputs consistently", () => {
|
|
const id1 = createAuditIdentifier("User@Example.COM", "email");
|
|
const id2 = createAuditIdentifier("user@example.com", "email");
|
|
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
test("should handle special characters in identifiers", () => {
|
|
const specialEmail = "user+test@example-domain.co.uk";
|
|
const identifier = createAuditIdentifier(specialEmail, "email");
|
|
|
|
expect(identifier).toMatch(/^email_/);
|
|
expect(identifier).not.toContain("user+test");
|
|
expect(identifier.length).toBe(38); // "email_" + 32 chars
|
|
});
|
|
|
|
test("should create different hashes for different prefixes", () => {
|
|
const input = "test@example.com";
|
|
const emailId = createAuditIdentifier(input, "email");
|
|
const ipId = createAuditIdentifier(input, "ip");
|
|
|
|
expect(emailId).not.toBe(ipId);
|
|
expect(emailId).toMatch(/^email_/);
|
|
expect(ipId).toMatch(/^ip_/);
|
|
});
|
|
|
|
test("should handle numeric identifiers", () => {
|
|
const numericId = "12345678";
|
|
const identifier = createAuditIdentifier(numericId, "user");
|
|
|
|
expect(identifier).toMatch(/^user_/);
|
|
expect(identifier).not.toContain("12345678");
|
|
});
|
|
});
|
|
|
|
describe("Rate Limiting", () => {
|
|
test("should always allow successful authentication logging", async () => {
|
|
// This test doesn't need Redis to be available as it short-circuits for success
|
|
mockCache.getRedisClient.mockResolvedValue(null);
|
|
|
|
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
|
|
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
|
|
});
|
|
|
|
describe("Bucket Time Alignment", () => {
|
|
test("should align timestamps to bucket boundaries for consistent keys across pods", async () => {
|
|
mockCache.getRedisClient.mockResolvedValue(null);
|
|
|
|
const RATE_LIMIT_WINDOW = 5 * 60 * 1000; // 5 minutes = 300000ms
|
|
|
|
// Test with a known aligned timestamp (start of hour for simplicity)
|
|
const alignedTime = 1700000000000; // Use this as our aligned bucket start
|
|
const bucketStart = Math.floor(alignedTime / RATE_LIMIT_WINDOW) * RATE_LIMIT_WINDOW;
|
|
|
|
// Verify bucket alignment logic with specific test cases
|
|
const testCases = [
|
|
{ timestamp: bucketStart, expected: bucketStart },
|
|
{ timestamp: bucketStart + 50000, expected: bucketStart }, // 50 seconds later
|
|
{ timestamp: bucketStart + 100000, expected: bucketStart }, // 1 min 40 sec later
|
|
{ timestamp: bucketStart + 200000, expected: bucketStart }, // 3 min 20 sec later
|
|
{ timestamp: bucketStart + RATE_LIMIT_WINDOW, expected: bucketStart + RATE_LIMIT_WINDOW }, // Next bucket
|
|
];
|
|
|
|
for (const { timestamp, expected } of testCases) {
|
|
const actualBucketStart = Math.floor(timestamp / RATE_LIMIT_WINDOW) * RATE_LIMIT_WINDOW;
|
|
expect(actualBucketStart).toBe(expected);
|
|
}
|
|
});
|
|
|
|
test("should create consistent cache keys with bucketed timestamps", async () => {
|
|
const { createCacheKey } = await import("@formbricks/cache");
|
|
const { createAuditIdentifier } = await import("./utils");
|
|
|
|
mockCache.getRedisClient.mockResolvedValue(null);
|
|
|
|
const identifier = "test@example.com";
|
|
const hashedIdentifier = createAuditIdentifier(identifier, "ratelimit");
|
|
|
|
const RATE_LIMIT_WINDOW = 5 * 60 * 1000; // 5 minutes = 300000ms
|
|
|
|
// Use a simple aligned time for testing
|
|
const baseTime = 1700000000000;
|
|
const bucketStart = Math.floor(baseTime / RATE_LIMIT_WINDOW) * RATE_LIMIT_WINDOW;
|
|
|
|
// Test that cache keys are consistent for the same bucket
|
|
const timestamp1 = bucketStart;
|
|
const timestamp2 = bucketStart + 60000; // 1 minute later in same bucket
|
|
|
|
const bucketStart1 = Math.floor(timestamp1 / RATE_LIMIT_WINDOW) * RATE_LIMIT_WINDOW;
|
|
const bucketStart2 = Math.floor(timestamp2 / RATE_LIMIT_WINDOW) * RATE_LIMIT_WINDOW;
|
|
|
|
// Both should align to the same bucket
|
|
expect(bucketStart1).toBe(bucketStart);
|
|
expect(bucketStart2).toBe(bucketStart);
|
|
|
|
// Both should generate the same cache key
|
|
const key1 = (createCacheKey.rateLimit.core as any)("auth", hashedIdentifier, bucketStart1);
|
|
const key2 = (createCacheKey.rateLimit.core as any)("auth", hashedIdentifier, bucketStart2);
|
|
expect(key1).toBe(key2);
|
|
|
|
const expectedKey = `rate_limit:auth:${hashedIdentifier}:${bucketStart}`;
|
|
expect(key1).toBe(expectedKey);
|
|
});
|
|
});
|
|
|
|
test("should implement fail-closed behavior when Redis is unavailable", async () => {
|
|
// Set Redis unavailable for this test
|
|
mockCache.getRedisClient.mockResolvedValue(null);
|
|
|
|
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("Redis Available - All Branch Coverage", () => {
|
|
let mockRedis: any;
|
|
let mockMulti: any;
|
|
|
|
beforeEach(() => {
|
|
// Clear mocks first
|
|
vi.clearAllMocks();
|
|
|
|
// Create comprehensive Redis mock
|
|
mockMulti = {
|
|
zRemRangeByScore: vi.fn().mockReturnThis(),
|
|
zCard: vi.fn().mockReturnThis(),
|
|
zAdd: vi.fn().mockReturnThis(),
|
|
expire: vi.fn().mockReturnThis(),
|
|
exec: vi.fn(),
|
|
};
|
|
|
|
mockRedis = {
|
|
multi: vi.fn().mockReturnValue(mockMulti),
|
|
zRange: vi.fn(),
|
|
isReady: true, // Add isReady property
|
|
};
|
|
|
|
// Reset the Redis mock for these specific tests
|
|
mockCache.getRedisClient.mockReset();
|
|
mockCache.getRedisClient.mockResolvedValue(mockRedis); // Use mockResolvedValue since it's now async
|
|
});
|
|
|
|
test("should handle Redis transaction failure - !results branch", async () => {
|
|
// Create fresh mock objects for this test
|
|
const testMockMulti = {
|
|
zRemRangeByScore: vi.fn().mockReturnThis(),
|
|
zCard: vi.fn().mockReturnThis(),
|
|
zAdd: vi.fn().mockReturnThis(),
|
|
expire: vi.fn().mockReturnThis(),
|
|
exec: vi.fn().mockResolvedValue(null), // Mock transaction returning null
|
|
};
|
|
|
|
const testMockRedis = {
|
|
multi: vi.fn().mockReturnValue(testMockMulti),
|
|
zRange: vi.fn(),
|
|
isReady: true,
|
|
};
|
|
|
|
// Reset and setup mock for this specific test
|
|
mockCache.getRedisClient.mockReset();
|
|
mockCache.getRedisClient.mockResolvedValue(testMockRedis);
|
|
|
|
const email = "transaction-failure@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
// Function should return false when Redis transaction fails (fail-closed behavior)
|
|
expect(result).toBe(false);
|
|
expect(mockCache.getRedisClient).toHaveBeenCalled();
|
|
expect(testMockRedis.multi).toHaveBeenCalled();
|
|
expect(testMockMulti.zRemRangeByScore).toHaveBeenCalled();
|
|
expect(testMockMulti.zCard).toHaveBeenCalled();
|
|
expect(testMockMulti.zAdd).toHaveBeenCalled();
|
|
expect(testMockMulti.expire).toHaveBeenCalled();
|
|
expect(testMockMulti.exec).toHaveBeenCalled();
|
|
});
|
|
|
|
test("should allow logging when currentCount <= AGGREGATION_THRESHOLD", async () => {
|
|
// Mock Redis transaction returning count <= threshold (assuming threshold is 3)
|
|
mockMulti.exec.mockResolvedValue([
|
|
null, // zRemRangeByScore result
|
|
2, // zCard result - below threshold
|
|
null, // zAdd result
|
|
null, // expire result
|
|
]);
|
|
|
|
const email = "below-threshold@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockMulti.exec).toHaveBeenCalled();
|
|
});
|
|
|
|
test("should allow logging when recentEntries.length === 0", async () => {
|
|
// Mock Redis transaction returning count above threshold
|
|
mockMulti.exec.mockResolvedValue([
|
|
null, // zRemRangeByScore result
|
|
5, // zCard result - above threshold
|
|
null, // zAdd result
|
|
null, // expire result
|
|
]);
|
|
|
|
// Mock zRange returning empty array
|
|
mockRedis.zRange.mockResolvedValue([]);
|
|
|
|
const email = "no-recent-entries@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockRedis.zRange).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"), -10, -1);
|
|
});
|
|
|
|
test("should allow logging on every 10th attempt - currentCount % 10 === 0", async () => {
|
|
const now = Date.now();
|
|
|
|
// Mock Redis transaction returning count that is divisible by 10
|
|
mockMulti.exec.mockResolvedValue([
|
|
null, // zRemRangeByScore result
|
|
10, // zCard result - 10th attempt
|
|
null, // zAdd result
|
|
null, // expire result
|
|
]);
|
|
|
|
// Mock zRange returning recent entries
|
|
mockRedis.zRange.mockResolvedValue([
|
|
`${now - 30000}:uuid1`, // 30 seconds ago
|
|
]);
|
|
|
|
const email = "tenth-attempt@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockRedis.zRange).toHaveBeenCalled();
|
|
});
|
|
|
|
test("should allow logging after 1 minute gap - timeSinceLastLog > 60000", async () => {
|
|
const now = Date.now();
|
|
|
|
// Mock Redis transaction returning count not divisible by 10
|
|
mockMulti.exec.mockResolvedValue([
|
|
null, // zRemRangeByScore result
|
|
7, // zCard result - 7th attempt (not divisible by 10)
|
|
null, // zAdd result
|
|
null, // expire result
|
|
]);
|
|
|
|
// Mock zRange returning entry older than 1 minute
|
|
mockRedis.zRange.mockResolvedValue([
|
|
`${now - 120000}:uuid1`, // 2 minutes ago
|
|
]);
|
|
|
|
const email = "one-minute-gap@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockRedis.zRange).toHaveBeenCalled();
|
|
});
|
|
|
|
test("should block logging when neither condition is met", async () => {
|
|
const now = Date.now();
|
|
|
|
// Mock Redis transaction returning count not divisible by 10
|
|
mockMulti.exec.mockResolvedValue([
|
|
null, // zRemRangeByScore result
|
|
7, // zCard result - 7th attempt (not divisible by 10)
|
|
null, // zAdd result
|
|
null, // expire result
|
|
]);
|
|
|
|
// Mock zRange returning recent entry (less than 1 minute)
|
|
mockRedis.zRange.mockResolvedValue([
|
|
`${now - 30000}:uuid1`, // 30 seconds ago
|
|
]);
|
|
|
|
const email = "blocked-logging@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockRedis.zRange).toHaveBeenCalled();
|
|
});
|
|
|
|
test("should handle Redis operation errors gracefully", async () => {
|
|
// Mock Redis multi throwing an error
|
|
mockMulti.exec.mockRejectedValue(new Error("Redis operation failed"));
|
|
|
|
const email = "redis-error@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockMulti.exec).toHaveBeenCalled();
|
|
});
|
|
|
|
test("should handle zRange errors gracefully", async () => {
|
|
// Mock successful transaction but zRange failing
|
|
mockMulti.exec.mockResolvedValue([
|
|
null, // zRemRangeByScore result
|
|
5, // zCard result - above threshold
|
|
null, // zAdd result
|
|
null, // expire result
|
|
]);
|
|
|
|
mockRedis.zRange.mockRejectedValue(new Error("zRange failed"));
|
|
|
|
const email = "zrange-error@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockRedis.zRange).toHaveBeenCalled();
|
|
});
|
|
|
|
test("should handle malformed timestamp in recent entries", async () => {
|
|
// Mock Redis transaction returning count not divisible by 10
|
|
mockMulti.exec.mockResolvedValue([
|
|
null, // zRemRangeByScore result
|
|
7, // zCard result - 7th attempt
|
|
null, // zAdd result
|
|
null, // expire result
|
|
]);
|
|
|
|
// Mock zRange returning entry with malformed timestamp
|
|
mockRedis.zRange.mockResolvedValue(["invalid-timestamp:uuid1"]);
|
|
|
|
const email = "malformed-timestamp@example.com";
|
|
const result = await shouldLogAuthFailure(email, false);
|
|
|
|
// Should handle parseInt(NaN) gracefully and still make a decision
|
|
expect(typeof result).toBe("boolean");
|
|
expect(mockRedis.zRange).toHaveBeenCalled();
|
|
});
|
|
|
|
test("should verify correct Redis key generation and operations", async () => {
|
|
mockMulti.exec.mockResolvedValue([
|
|
null, // zRemRangeByScore result
|
|
2, // zCard result - below threshold
|
|
null, // zAdd result
|
|
null, // expire result
|
|
]);
|
|
|
|
const email = "key-generation@example.com";
|
|
await shouldLogAuthFailure(email, false);
|
|
|
|
// Verify correct Redis operations were called
|
|
expect(mockRedis.multi).toHaveBeenCalled();
|
|
expect(mockMulti.zRemRangeByScore).toHaveBeenCalledWith(
|
|
expect.stringContaining("rate_limit:auth:"),
|
|
0,
|
|
expect.any(Number)
|
|
);
|
|
expect(mockMulti.zCard).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"));
|
|
expect(mockMulti.zAdd).toHaveBeenCalledWith(
|
|
expect.stringContaining("rate_limit:auth:"),
|
|
expect.objectContaining({
|
|
score: expect.any(Number),
|
|
value: expect.stringMatching(/^\d+:.+$/),
|
|
})
|
|
);
|
|
expect(mockMulti.expire).toHaveBeenCalledWith(
|
|
expect.stringContaining("rate_limit:auth:"),
|
|
expect.any(Number)
|
|
);
|
|
});
|
|
|
|
test("should handle edge case with empty identifier", async () => {
|
|
const result = await shouldLogAuthFailure("", false);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("should handle edge case with null identifier", async () => {
|
|
// @ts-expect-error - Testing runtime behavior with null
|
|
const result = await shouldLogAuthFailure(null, false);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("should handle edge case with undefined identifier", async () => {
|
|
// @ts-expect-error - Testing runtime behavior with undefined
|
|
const result = await shouldLogAuthFailure(undefined, false);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|