Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions backend/services/auth/__tests__/accountRecoveryService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { AccountRecoveryService } from '../domain/AccountRecoveryService';

describe('AccountRecoveryService', () => {
let service: AccountRecoveryService;

beforeEach(() => {
service = new AccountRecoveryService({
maxInitiationsPerIdentifier: 3,
maxInitiationsPerIp: 5,
maxVerificationAttempts: 2,
});
});

describe('Full Recovery Flow', () => {
it('successfully initiates, verifies identity, and completes recovery', async () => {
// Step 1: Initiate
const { recoveryId, rawToken } = await service.initiateRecovery({
identifier: 'user@example.com',
ip: '192.0.2.1',
userAgent: 'JestTestRunner',
challengeAnswer: 'my_pet_dog',
});

expect(recoveryId).toMatch(/^rec_/);
expect(rawToken).toBeDefined();

// Step 2: Verify Identity
const verifyResult = await service.verifyIdentity({
recoveryId,
token: rawToken,
challengeAnswer: 'my_pet_dog',
ip: '192.0.2.1',
userAgent: 'JestTestRunner',
});

expect(verifyResult.verified).toBe(true);
expect(verifyResult.grantToken).toBeDefined();

// Step 3: Complete Recovery
const completeResult = await service.completeRecovery({
recoveryId,
grantToken: verifyResult.grantToken,
ip: '192.0.2.1',
userAgent: 'JestTestRunner',
});

expect(completeResult.success).toBe(true);
expect(completeResult.identifier).toBe('user@example.com');
expect(completeResult.completedAt).toBeDefined();
});

it('prevents replay attack using consumed grant token', async () => {
const { recoveryId, rawToken } = await service.initiateRecovery({
identifier: 'user2@example.com',
ip: '192.0.2.1',
userAgent: 'JestTestRunner',
});

const { grantToken } = await service.verifyIdentity({
recoveryId,
token: rawToken,
ip: '192.0.2.1',
userAgent: 'JestTestRunner',
});

// First completion succeeds
await service.completeRecovery({
recoveryId,
grantToken,
ip: '192.0.2.1',
userAgent: 'JestTestRunner',
});

// Second completion fails
await expect(
service.completeRecovery({
recoveryId,
grantToken,
ip: '192.0.2.1',
userAgent: 'JestTestRunner',
})
).rejects.toThrow(/not in verified status/);
});
});

describe('Identity Verification Security', () => {
it('rejects incorrect recovery token and tracks failed attempts', async () => {
const { recoveryId } = await service.initiateRecovery({
identifier: 'security@example.com',
ip: '192.0.2.2',
userAgent: 'Browser',
});

await expect(
service.verifyIdentity({
recoveryId,
token: 'wrong_token',
ip: '192.0.2.2',
userAgent: 'Browser',
})
).rejects.toThrow(/Identity verification failed/);
});

it('locks recovery session when max attempts are exceeded', async () => {
const { recoveryId, rawToken } = await service.initiateRecovery({
identifier: 'lockout@example.com',
ip: '192.0.2.3',
userAgent: 'Browser',
});

// Attempt 1 fails
await expect(
service.verifyIdentity({ recoveryId, token: 'bad1', ip: '192.0.2.3', userAgent: 'Browser' })
).rejects.toThrow();

// Attempt 2 fails and locks session (maxVerificationAttempts = 2)
await expect(
service.verifyIdentity({ recoveryId, token: 'bad2', ip: '192.0.2.3', userAgent: 'Browser' })
).rejects.toThrow();

// Attempt 3 with correct token now rejected because session is locked
await expect(
service.verifyIdentity({ recoveryId, token: rawToken, ip: '192.0.2.3', userAgent: 'Browser' })
).rejects.toThrow(/locked/);
});
});

describe('Rate Limiting', () => {
it('enforces rate limits per identifier', async () => {
const identifier = 'ratelimit@example.com';
// 3 attempts are allowed
await service.initiateRecovery({ identifier, ip: '1.1.1.1', userAgent: 'Test' });
await service.initiateRecovery({ identifier, ip: '1.1.1.2', userAgent: 'Test' });
await service.initiateRecovery({ identifier, ip: '1.1.1.3', userAgent: 'Test' });

// 4th attempt exceeds limit
await expect(
service.initiateRecovery({ identifier, ip: '1.1.1.4', userAgent: 'Test' })
).rejects.toThrow(/Too many recovery requests/);
});

it('enforces rate limits per IP address', async () => {
const ip = '10.20.30.40';
for (let i = 0; i < 5; i++) {
await service.initiateRecovery({ identifier: `user_${i}@example.com`, ip, userAgent: 'Test' });
}

// 6th attempt from same IP exceeds limit
await expect(
service.initiateRecovery({ identifier: 'user_overflow@example.com', ip, userAgent: 'Test' })
).rejects.toThrow(/Too many recovery requests/);
});
});

describe('Audit Logging', () => {
it('records audit trail for every action across recovery lifecycle', async () => {
const identifier = 'audit@example.com';
const { recoveryId, rawToken } = await service.initiateRecovery({
identifier,
ip: '10.0.0.1',
userAgent: 'Agent',
});

const { grantToken } = await service.verifyIdentity({
recoveryId,
token: rawToken,
ip: '10.0.0.1',
userAgent: 'Agent',
});

await service.completeRecovery({
recoveryId,
grantToken,
ip: '10.0.0.1',
userAgent: 'Agent',
});

const auditTrail = service.getAuditTrail(identifier);
const actions = auditTrail.map((a) => a.action);

expect(actions).toContain('recovery_initiated');
expect(actions).toContain('identity_verified');
expect(actions).toContain('recovery_completed');

expect(auditTrail[0].ip).toBe('10.0.0.1');
expect(auditTrail[0].timestamp).toBeDefined();
});
});
});
173 changes: 173 additions & 0 deletions backend/services/auth/__tests__/loginAnomalyService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {
LoginAnomalyService,
calculateHaversineDistanceKm,
type LoginAnomalyAlert,
} from '../domain/LoginAnomalyService';

describe('LoginAnomalyService', () => {
let service: LoginAnomalyService;

beforeEach(() => {
service = new LoginAnomalyService();
});

describe('Haversine distance formula', () => {
it('calculates distance between London and Paris accurately', () => {
// London: 51.5074, -0.1278; Paris: 48.8566, 2.3522 (~340 km)
const distance = calculateHaversineDistanceKm(51.5074, -0.1278, 48.8566, 2.3522);
expect(Math.round(distance)).toBeGreaterThan(330);
expect(Math.round(distance)).toBeLessThan(360);
});
});

describe('Impossible Travel Heuristic', () => {
it('detects impossible travel across continents within 1 hour', async () => {
const now = Date.now();
// First login in New York (lat: 40.7128, lon: -74.0060)
await service.evaluateLogin({
userId: 'user-123',
ip: '198.51.100.1',
deviceFingerprint: 'dev-1',
userAgent: 'Mozilla/5.0',
timestamp: now,
geo: { lat: 40.7128, lon: -74.0060, city: 'New York' },
});

// Second login 30 minutes later in Tokyo (lat: 35.6762, lon: 139.6503, ~10,800 km away)
const result = await service.evaluateLogin({
userId: 'user-123',
ip: '203.0.113.1',
deviceFingerprint: 'dev-1',
userAgent: 'Mozilla/5.0',
timestamp: now + 30 * 60 * 1000,
geo: { lat: 35.6762, lon: 139.6503, city: 'Tokyo' },
});

expect(result.isAnomalous).toBe(true);
expect(result.triggeredHeuristics).toContain('impossible_travel');
expect(result.severity).toBe('critical');
expect(result.alert).toBeDefined();
});
});

describe('Unrecognized Device Heuristic', () => {
it('flags an unrecognized device after a known device exists', async () => {
const now = Date.now();
// Baseline login from device-A
await service.evaluateLogin({
userId: 'user-456',
ip: '192.0.2.1',
deviceFingerprint: 'device-fingerprint-A',
userAgent: 'Chrome on MacOS',
timestamp: now,
});

// Login from completely new device-B
const result = await service.evaluateLogin({
userId: 'user-456',
ip: '192.0.2.1',
deviceFingerprint: 'device-fingerprint-B',
userAgent: 'Firefox on Linux',
timestamp: now + 60 * 1000,
});

expect(result.triggeredHeuristics).toContain('unrecognized_device');
});
});

describe('Failed Attempts Burst Heuristic', () => {
it('detects preceding burst of failed login attempts', async () => {
const now = Date.now();
// Record 3 failed attempts
service.recordFailedLogin({
userId: 'user-789',
ip: '198.51.100.5',
deviceFingerprint: 'dev-fail',
userAgent: 'Mozilla/5.0',
timestamp: now - 60_000,
});
service.recordFailedLogin({
userId: 'user-789',
ip: '198.51.100.5',
deviceFingerprint: 'dev-fail',
userAgent: 'Mozilla/5.0',
timestamp: now - 40_000,
});
service.recordFailedLogin({
userId: 'user-789',
ip: '198.51.100.5',
deviceFingerprint: 'dev-fail',
userAgent: 'Mozilla/5.0',
timestamp: now - 20_000,
});

// Now successful login
const result = await service.evaluateLogin({
userId: 'user-789',
ip: '198.51.100.5',
deviceFingerprint: 'dev-fail',
userAgent: 'Mozilla/5.0',
timestamp: now,
});

expect(result.triggeredHeuristics).toContain('failed_attempts_burst');
});
});

describe('Alerts Emitted & Tunable Configuration', () => {
it('emits alerts to registered listeners and stores in history', async () => {
const receivedAlerts: LoginAnomalyAlert[] = [];
service.onAlert((alert) => {
receivedAlerts.push(alert);
});

const now = Date.now();
await service.evaluateLogin({
userId: 'user-alert',
ip: '10.0.0.1',
deviceFingerprint: 'dev-1',
userAgent: 'Agent1',
timestamp: now,
geo: { lat: 0, lon: 0 },
});

await service.evaluateLogin({
userId: 'user-alert',
ip: '10.0.0.2',
deviceFingerprint: 'dev-2',
userAgent: 'Agent2',
timestamp: now + 60 * 1000,
geo: { lat: 45, lon: 45 }, // massive jump in 1 minute
});

expect(receivedAlerts.length).toBeGreaterThan(0);
const alerts = service.getAlerts('user-alert');
expect(alerts.length).toBeGreaterThan(0);
expect(alerts[0].userId).toBe('user-alert');
});

it('tunes thresholds dynamically via updateConfig', async () => {
service.updateConfig({ riskThreshold: 0.9 });
expect(service.getConfig().riskThreshold).toBe(0.9);

// Invalid threshold rejected
expect(() => service.updateConfig({ riskThreshold: 1.5 })).toThrow('riskThreshold must be between 0 and 1');
});

it('bypasses anomaly detection when disabled', async () => {
service.updateConfig({ enabled: false });
const now = Date.now();
const result = await service.evaluateLogin({
userId: 'user-disabled',
ip: '10.0.0.1',
deviceFingerprint: 'dev-1',
userAgent: 'Agent1',
timestamp: now,
geo: { lat: 0, lon: 0 },
});

expect(result.isAnomalous).toBe(false);
expect(result.riskScore).toBe(0);
});
});
});
Loading
Loading