diff --git a/services/platform-service/src/modules/auth/auth.routes.test.ts b/services/platform-service/src/modules/auth/auth.routes.test.ts index 2b75a77e..a8029ef7 100644 --- a/services/platform-service/src/modules/auth/auth.routes.test.ts +++ b/services/platform-service/src/modules/auth/auth.routes.test.ts @@ -37,6 +37,43 @@ vi.mock('./jwt.js', () => jwtMock); vi.mock('../products/cache.js', () => productCacheMock); vi.mock('../subscriptions/repository.js', () => subscriptionRepoMock); vi.mock('../licenses/repository.js', () => licenseRepoMock); +vi.mock('./login-events/repository.js', () => ({ + record: vi.fn().mockResolvedValue(undefined), + countRecentFailures: vi.fn().mockResolvedValue(0), +})); +vi.mock('./login-events/risk-scorer.js', () => ({ + scoreLoginRisk: vi.fn().mockReturnValue({ score: 0, level: 'low', flags: [] }), +})); +vi.mock('./mfa/repository.js', () => ({ + getByUserId: vi.fn().mockResolvedValue(null), +})); +vi.mock('./mfa/challenge-store.js', () => ({ + createChallenge: vi.fn().mockReturnValue('mfa_test'), +})); +vi.mock('./devices/repository.js', () => ({ + getByFingerprint: vi.fn().mockResolvedValue(null), + isDeviceTrusted: vi.fn().mockReturnValue(false), +})); +vi.mock('../../lib/config.js', () => ({ + config: { + PORT: 4003, + HOST: '0.0.0.0', + NODE_ENV: 'test', + SERVICE_NAME: 'platform-service', + COSMOS_ENDPOINT: 'https://fake.documents.azure.com:443/', + COSMOS_KEY: 'dGVzdA==', + COSMOS_DATABASE: 'test', + JWT_SECRET: 'test-secret-at-least-32-chars-long!!', + MICROSOFT_TENANT_ID: 'common', + WEBAUTHN_RP_ID: 'localhost', + WEBAUTHN_RP_NAME: 'Test', + USAGE_WARN_THRESHOLD: 0.8, + BACKEND_URL: 'http://localhost:8000', + }, +})); +vi.mock('../../lib/event-bus.js', () => ({ + bus: { emit: vi.fn().mockResolvedValue(undefined) }, +})); describe('authRoutes register provisioning', () => { beforeEach(() => { @@ -70,7 +107,7 @@ describe('authRoutes register provisioning', () => { }); afterEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); it('provisions subscription/license from product defaults during register', async () => { diff --git a/services/platform-service/src/modules/auth/smartauth.test.ts b/services/platform-service/src/modules/auth/smartauth.test.ts index 31580420..7c34ece6 100644 --- a/services/platform-service/src/modules/auth/smartauth.test.ts +++ b/services/platform-service/src/modules/auth/smartauth.test.ts @@ -463,6 +463,109 @@ describe('Login event types', () => { }); }); +// ── Login flow integration tests ───────────────────────────── + +describe('Login flow — MFA challenge integration', () => { + it('createChallenge from mfa/challenge-store returns mfa_ prefixed token', async () => { + const { createChallenge, consumeChallenge, _clearChallenges } = + await import('./mfa/challenge-store.js'); + _clearChallenges(); + const token = createChallenge({ + userId: 'usr_mfa', + productId: 'lysnrai', + email: 'mfa@test.com', + role: 'user', + plan: 'pro', + }); + expect(token).toMatch(/^mfa_/); + const data = consumeChallenge(token); + expect(data?.plan).toBe('pro'); + }); +}); + +describe('Risk scorer — edge cases', () => { + it('returns low for OAuth on known device', async () => { + const { scoreLoginRisk } = await import('./login-events/risk-scorer.js'); + const result = scoreLoginRisk({ + ip: '10.0.0.1', + isNewIp: false, + isNewDevice: false, + isDeviceTrusted: true, + recentFailures: 0, + method: 'oauth_google', + hourOfDay: 14, + }); + expect(result.score).toBe(0); + expect(result.level).toBe('low'); + }); + + it('password on new device at 3am scores higher than OAuth same conditions', async () => { + const { scoreLoginRisk } = await import('./login-events/risk-scorer.js'); + const passwordResult = scoreLoginRisk({ + ip: '5.5.5.5', + isNewIp: true, + isNewDevice: true, + isDeviceTrusted: false, + recentFailures: 0, + method: 'password', + hourOfDay: 3, + }); + const oauthResult = scoreLoginRisk({ + ip: '5.5.5.5', + isNewIp: true, + isNewDevice: true, + isDeviceTrusted: false, + recentFailures: 0, + method: 'oauth_google', + hourOfDay: 3, + }); + expect(passwordResult.score).toBeGreaterThan(oauthResult.score); + expect(passwordResult.flags).toContain('password_new_device'); + expect(oauthResult.flags).not.toContain('password_new_device'); + }); +}); + +describe('Login events wiring exports', () => { + it('login-events/repository exports record + listByUser + countRecentFailures', async () => { + const repo = await import('./login-events/repository.js'); + expect(typeof repo.record).toBe('function'); + expect(typeof repo.listByUser).toBe('function'); + expect(typeof repo.countRecentFailures).toBe('function'); + }); + + it('devices/repository exports isDeviceTrusted as pure function', async () => { + const repo = await import('./devices/repository.js'); + // isDeviceTrusted is a sync function that works without DB + expect(repo.isDeviceTrusted(null)).toBe(false); + expect( + repo.isDeviceTrusted({ + id: 'dev_x', + userId: 'u', + productId: 'smartauth', + fingerprint: 'x', + trustLevel: 'trusted', + deviceInfo: {}, + trustExpiresAt: new Date(Date.now() + 86400000).toISOString(), + createdAt: '2026-01-01T00:00:00Z', + lastSeenAt: '2026-01-01T00:00:00Z', + }) + ).toBe(true); + expect( + repo.isDeviceTrusted({ + id: 'dev_x', + userId: 'u', + productId: 'smartauth', + fingerprint: 'x', + trustLevel: 'trusted', + deviceInfo: {}, + trustExpiresAt: new Date(Date.now() - 86400000).toISOString(), + createdAt: '2026-01-01T00:00:00Z', + lastSeenAt: '2026-01-01T00:00:00Z', + }) + ).toBe(false); + }); +}); + // ── SmartAuth event types in @bytelyst/events ─────────────── describe('SmartAuth event schemas', () => {