import { vi, describe, it, expect, beforeEach } from 'vitest'; const mockStorage = vi.hoisted(() => ({ getUserByEmail: vi.fn(), getUserByEmailAndSlug: vi.fn(), getUserWithOrganization: vi.fn(), getUser: vi.fn(), updateUser: vi.fn(), createUserSession: vi.fn(), getAppRolePermissions: vi.fn(), getOrganization: vi.fn(), getUserByResetPasswordToken: vi.fn(), getInvitationByToken: vi.fn(), })); const mockEmailService = vi.hoisted(() => ({ sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined), })); vi.mock('../server/db', () => ({ db: {}, pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, withTenant: (_orgId: number, fn: () => unknown) => fn(), withSuperAdmin: (fn: (db: unknown) => unknown) => fn({}), openTenantCtx: vi.fn().mockResolvedValue({ run: (fn: () => void) => fn(), release: vi.fn(), }), openSuperAdminCtx: vi.fn().mockResolvedValue(undefined), _tenantCtx: { getStore: vi.fn().mockReturnValue(null) }, })); vi.mock('../server/storage', () => ({ storage: mockStorage })); // bcrypt замокан ради скорости тестов: хэш = 'hash:<пароль>'. vi.mock('../server/utils/password', () => ({ hashPassword: vi.fn(async (p: string) => `hash:${p}`), verifyPassword: vi.fn(async (p: string, h: string) => h === `hash:${p}`), generateTempPassword: vi.fn(() => 'Temp1234!'), })); vi.mock('../server/services/email.service', () => ({ emailService: mockEmailService, EmailService: class {}, })); vi.mock('../server/services/notification.service', () => ({ notificationService: { emit: vi.fn(), on: vi.fn(), sendNotification: vi.fn().mockResolvedValue(undefined), createDefaultSubscriptions: vi.fn().mockResolvedValue(undefined), processEvent: vi.fn().mockResolvedValue(undefined), }, EVENT_TYPES: {}, })); // Глобальные rate-limit'еры отключены, чтобы не мешать сериям запросов в тестах. vi.mock('../server/routes/shared', () => ({ authLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), refreshLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), })); vi.mock('../server/utils/audit', () => ({ logAudit: vi.fn().mockResolvedValue(undefined), getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })); import express from 'express'; import request from 'supertest'; import { Router } from 'express'; import { registerAuthCoreRoutes } from '../server/routes/auth.core.routes'; import { sanitizeReturnTo } from '../server/utils/return-to'; import { clearLoginAttempts, MAX_FAILED_ATTEMPTS, } from '../server/utils/login-attempts'; const PASSWORD = 'Secret123!'; function makeUser(overrides: Record = {}) { return { id: 1, organizationId: 1, email: 'user@example.com', passwordHash: `hash:${PASSWORD}`, firstName: 'Иван', lastName: 'Иванов', middleName: null, position: null, appRole: 'user', isActive: true, organization: { id: 1, name: 'Тест', slug: 'test', displayName: 'Тест', isActive: true, }, ...overrides, }; } function setupExistingUser(user = makeUser()) { mockStorage.getUserByEmail.mockResolvedValue(user); mockStorage.getUserWithOrganization.mockResolvedValue(user); mockStorage.getAppRolePermissions.mockResolvedValue([]); mockStorage.updateUser.mockResolvedValue(user); mockStorage.createUserSession.mockResolvedValue({}); return user; } function buildApp() { const app = express(); app.use(express.json()); const router = Router(); registerAuthCoreRoutes(router); app.use(router); return app; } describe('auth anti-enumeration', () => { beforeEach(() => { vi.clearAllMocks(); clearLoginAttempts(); }); it('несуществующий email, неверный пароль и неактивный аккаунт дают одинаковый 401 и текст', async () => { const app = buildApp(); // 1. Пользователь не найден mockStorage.getUserByEmail.mockResolvedValue(undefined); const notFound = await request(app) .post('/api/auth/login') .send({ email: 'ghost@example.com', password: PASSWORD }); // 2. Неверный пароль setupExistingUser(); const wrongPassword = await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: 'WrongPass1!' }); // 3. Аккаунт деактивирован setupExistingUser(makeUser({ isActive: false })); const inactive = await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: PASSWORD }); for (const res of [notFound, wrongPassword, inactive]) { expect(res.status).toBe(401); expect(res.body).toEqual({ success: false, error: 'Неверный email или пароль' }); } }); it('успешный логин возвращает 200, пользователя и токены', async () => { const app = buildApp(); setupExistingUser(); const res = await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: PASSWORD }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.user.email).toBe('user@example.com'); expect(res.body.tokens.accessToken).toBeTruthy(); expect(res.body.tokens.refreshToken).toBeTruthy(); }); }); describe('per-account лимит попыток логина', () => { beforeEach(() => { vi.clearAllMocks(); clearLoginAttempts(); setupExistingUser(); }); it(`после ${MAX_FAILED_ATTEMPTS} неудачных попыток — блокировка, даже с верным паролем`, async () => { const app = buildApp(); for (let i = 1; i < MAX_FAILED_ATTEMPTS; i++) { const res = await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: 'WrongPass1!' }); expect(res.status).toBe(401); expect(res.body.error).toBe('Неверный email или пароль'); } // Попытка, на которой срабатывает блокировка const locking = await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: 'WrongPass1!' }); expect(locking.status).toBe(401); expect(locking.body.error).toContain('Слишком много неудачных попыток'); // Верный пароль во время блокировки тоже отклоняется const duringLock = await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: PASSWORD }); expect(duringLock.status).toBe(401); expect(duringLock.body.error).toContain('Слишком много неудачных попыток'); }); it('успешный вход сбрасывает счётчик неудачных попыток', async () => { const app = buildApp(); for (let i = 0; i < MAX_FAILED_ATTEMPTS - 1; i++) { await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: 'WrongPass1!' }); } const ok = await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: PASSWORD }); expect(ok.status).toBe(200); // Счётчик сброшен: ещё MAX-1 неудачных попыток не блокируют аккаунт for (let i = 0; i < MAX_FAILED_ATTEMPTS - 1; i++) { const res = await request(app) .post('/api/auth/login') .send({ email: 'user@example.com', password: 'WrongPass1!' }); expect(res.body.error).toBe('Неверный email или пароль'); } }); }); describe('forgot-password anti-enumeration', () => { beforeEach(() => { vi.clearAllMocks(); clearLoginAttempts(); }); it('ответ одинаковый для существующего и несуществующего email', async () => { const app = buildApp(); mockStorage.getUserByEmail.mockResolvedValue(undefined); const missing = await request(app) .post('/api/auth/forgot-password') .send({ email: 'ghost@example.com' }); setupExistingUser(); mockStorage.getOrganization.mockResolvedValue({ id: 1, name: 'Тест', displayName: 'Тест' }); const existing = await request(app) .post('/api/auth/forgot-password') .send({ email: 'user@example.com' }); expect(missing.status).toBe(200); expect(existing.status).toBe(200); expect(missing.body).toEqual(existing.body); expect(missing.body.success).toBe(true); expect(missing.body.message).toContain('Если аккаунт'); // Письмо отправлено только для существующего аккаунта expect(mockEmailService.sendPasswordResetEmail).toHaveBeenCalledTimes(1); }); it('returnTo с open redirect отбрасывается, относительный путь проходит', async () => { const app = buildApp(); setupExistingUser(); mockStorage.getOrganization.mockResolvedValue({ id: 1, name: 'Тест', displayName: 'Тест' }); await request(app) .post('/api/auth/forgot-password') .send({ email: 'user@example.com', returnTo: '//evil.com' }); expect(mockEmailService.sendPasswordResetEmail).toHaveBeenLastCalledWith( 'user@example.com', 'Иван', expect.any(String), 'Тест', undefined, ); await request(app) .post('/api/auth/forgot-password') .send({ email: 'user@example.com', returnTo: 'https://evil.com/x' }); expect(mockEmailService.sendPasswordResetEmail).toHaveBeenLastCalledWith( 'user@example.com', 'Иван', expect.any(String), 'Тест', undefined, ); await request(app) .post('/api/auth/forgot-password') .send({ email: 'user@example.com', returnTo: '/home' }); expect(mockEmailService.sendPasswordResetEmail).toHaveBeenLastCalledWith( 'user@example.com', 'Иван', expect.any(String), 'Тест', '/home', ); }); }); describe('sanitizeReturnTo', () => { it('принимает относительные пути', () => { expect(sanitizeReturnTo('/')).toBe('/'); expect(sanitizeReturnTo('/home')).toBe('/home'); expect(sanitizeReturnTo('/forms/1/tasks/2?tab=chat')).toBe('/forms/1/tasks/2?tab=chat'); }); it('отклоняет open redirect варианты', () => { expect(sanitizeReturnTo(undefined)).toBeUndefined(); expect(sanitizeReturnTo('')).toBeUndefined(); expect(sanitizeReturnTo('https://evil.com')).toBeUndefined(); expect(sanitizeReturnTo('//evil.com')).toBeUndefined(); expect(sanitizeReturnTo('/\\evil.com')).toBeUndefined(); expect(sanitizeReturnTo('javascript:alert(1)')).toBeUndefined(); expect(sanitizeReturnTo('evil.com')).toBeUndefined(); expect(sanitizeReturnTo(42)).toBeUndefined(); }); });