import { vi, describe, it, expect, beforeEach } from 'vitest'; import crypto from 'crypto'; const mockStorage = vi.hoisted(() => ({ getUserByEmail: vi.fn(), getUserByEmailAndSlug: vi.fn(), getUserWithOrganization: vi.fn(), getUser: vi.fn(), updateUser: vi.fn(), createUserSession: vi.fn(), getUserSessionByToken: vi.fn(), getUserSessionByParentToken: vi.fn(), getActiveSessionInFamily: vi.fn(), rotateUserSession: vi.fn(), markSessionReplaced: vi.fn(), revokeSession: vi.fn(), revokeSessionFamily: vi.fn(), revokeAllUserSessions: vi.fn(), getAppRolePermissions: vi.fn(), getOrganization: vi.fn(), getUserByResetPasswordToken: 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(), }, EVENT_TYPES: {}, })); 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 { authService } from '../server/services/auth.service'; import { generateTokens, hashToken } from '../server/utils/jwt'; import { clearLoginAttempts } 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 makeSession(plainRefreshToken: string, overrides: Record = {}) { return { id: 10, userId: 1, refreshToken: null, refreshTokenHash: hashToken(plainRefreshToken), familyId: 'family-1', familyCreatedAt: new Date(), parentRefreshToken: null, parentRefreshTokenHash: null, isReplaced: false, isRevoked: false, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), remember: false, createdAt: new Date(), ...overrides, }; } function buildApp() { const app = express(); app.use(express.json()); const router = Router(); registerAuthCoreRoutes(router); app.use(router); return app; } describe('hashToken', () => { it('возвращает детерминированный sha256-хэш в hex (64 символа)', () => { const token = 'some-refresh-token'; const expected = crypto.createHash('sha256').update(token).digest('hex'); expect(hashToken(token)).toBe(expected); expect(hashToken(token)).toMatch(/^[0-9a-f]{64}$/); expect(hashToken(token)).toBe(hashToken(token)); expect(hashToken(token)).not.toBe(hashToken(token + 'x')); }); }); describe('refresh-токены: выпуск по хэшу (plain не сохраняется)', () => { beforeEach(() => { vi.clearAllMocks(); clearLoginAttempts(); }); it('при логине в БД уходит только refreshTokenHash, plain refreshToken не передаётся', 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); const plainRefresh: string = res.body.tokens.refreshToken; expect(plainRefresh).toBeTruthy(); const sessionArg = mockStorage.createUserSession.mock.calls[0][0]; expect(sessionArg.refreshTokenHash).toBe(hashToken(plainRefresh)); expect(sessionArg.refreshToken).toBeUndefined(); }); it('refresh по валидному токену: ротация с хэшами, plain в createUserSession не уходит', async () => { const user = setupExistingUser(); const { refreshToken: oldPlain } = generateTokens({ userId: 1, organizationId: 1, appRole: 'user' }, false, 'family-1'); const session = makeSession(oldPlain); mockStorage.getUserSessionByToken.mockResolvedValue(session); mockStorage.rotateUserSession.mockResolvedValue({ id: 2 }); const result = await authService.refreshToken(oldPlain); expect(result.success).toBe(true); expect(result.tokens?.refreshToken).toBeTruthy(); // jti в payload: токены уникальны всегда, даже при ротации в ту же секунду // Поиск сессии шёл по переданному plain-токену (хэширование — внутри storage) expect(mockStorage.getUserSessionByToken).toHaveBeenCalledWith(oldPlain); // Атомарная ротация: replaced + insert в одной транзакции (storage.rotateUserSession) const newPlain = result.tokens!.refreshToken; expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(session.id, expect.objectContaining({ refreshTokenHash: hashToken(newPlain), parentRefreshTokenHash: hashToken(oldPlain), familyId: 'family-1', })); const newSessionArg = mockStorage.rotateUserSession.mock.calls[0][1]; expect(newSessionArg.refreshToken).toBeUndefined(); expect(newSessionArg.parentRefreshToken).toBeUndefined(); expect(user.id).toBe(1); }); it('невалидный токен не находится: ни как текущий, ни как родительский', async () => { setupExistingUser(); mockStorage.getUserSessionByToken.mockResolvedValue(undefined); mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined); const result = await authService.refreshToken('forged-token'); expect(result.success).toBe(false); expect(result.error).toContain('Недействительный'); expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled(); expect(mockStorage.createUserSession).not.toHaveBeenCalled(); }); it('reuse detection: отозванная/старая rotated-сессия → отзыв всей family', async () => { setupExistingUser(); mockStorage.getUserSessionByToken.mockResolvedValue(undefined); // Rotated-сессия старая (grace period 5 минут истёк) или отозванная const rotated = makeSession('old-plain', { isRevoked: false, createdAt: new Date(Date.now() - 10 * 60 * 1000), }); mockStorage.getUserSessionByParentToken.mockResolvedValue(rotated); mockStorage.revokeSessionFamily.mockResolvedValue(undefined); const result = await authService.refreshToken('old-plain'); expect(result.success).toBe(false); expect(mockStorage.revokeSessionFamily).toHaveBeenCalledWith('family-1'); }); it('grace period: свежая rotated-сессия → активная сессия family ротируется, клиент получает новую пару', async () => { setupExistingUser(); const { refreshToken: activePlain } = generateTokens({ userId: 1, organizationId: 1, appRole: 'user' }, false, 'family-1'); const activeSession = makeSession(activePlain, { id: 11 }); mockStorage.getUserSessionByToken.mockResolvedValue(undefined); mockStorage.getUserSessionByParentToken.mockResolvedValue( makeSession('stale-plain', { createdAt: new Date() }), ); mockStorage.getActiveSessionInFamily.mockResolvedValue(activeSession); mockStorage.rotateUserSession.mockResolvedValue({ id: 12 }); const result = await authService.refreshToken('stale-plain'); expect(result.success).toBe(true); // Plain-токен активной сессии в БД не хранится — выдаётся пара через ротацию expect(result.tokens?.refreshToken).toBeTruthy(); expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(activeSession.id, expect.objectContaining({ refreshTokenHash: hashToken(result.tokens!.refreshToken), })); }); it('grace period: повтор СТАРОГО токена, replaced-сессия найдена по hash → successor ротируется, новая пара, без revoke', async () => { setupExistingUser(); const oldPlain = 'old-plain-race'; // Старая сессия всё ещё находится по refreshTokenHash (markSessionReplaced // hash не очищает) и помечена isReplaced const replaced = makeSession(oldPlain, { id: 10, isReplaced: true }); // Successor-сессия, созданная winner-запросом чуть раньше (в пределах окна) const successor = makeSession('new-plain-race', { id: 11, createdAt: new Date() }); mockStorage.getUserSessionByToken.mockResolvedValue(replaced); mockStorage.getUserSessionByParentToken.mockResolvedValue(successor); mockStorage.getActiveSessionInFamily.mockResolvedValue(successor); mockStorage.rotateUserSession.mockResolvedValue({ id: 12 }); const result = await authService.refreshToken(oldPlain); expect(result.success).toBe(true); expect(result.tokens?.refreshToken).toBeTruthy(); // Ротируется successor (активная сессия family), family НЕ отзывается expect(mockStorage.rotateUserSession).toHaveBeenCalledWith(successor.id, expect.objectContaining({ refreshTokenHash: hashToken(result.tokens!.refreshToken), })); expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); expect(result.clearCookies).toBeUndefined(); }); it('replaced-сессия без successor: 401 БЕЗ стирания cookie и БЕЗ revoke family', async () => { setupExistingUser(); const oldPlain = 'old-plain-no-successor'; const replaced = makeSession(oldPlain, { id: 10, isReplaced: true }); mockStorage.getUserSessionByToken.mockResolvedValue(replaced); mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined); const result = await authService.refreshToken(oldPlain); expect(result.success).toBe(false); expect(result.clearCookies).toBe(false); expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled(); }); it('reuse attack: повтор старого токена ПОСЛЕ grace-окна → revoke family (isReplaced-ветка)', async () => { setupExistingUser(); const oldPlain = 'old-plain-after-window'; const replaced = makeSession(oldPlain, { id: 10, isReplaced: true }); const successor = makeSession('new-plain-after-window', { id: 11, createdAt: new Date(Date.now() - 10 * 60 * 1000), // 10 минут назад — окно истекло }); mockStorage.getUserSessionByToken.mockResolvedValue(replaced); mockStorage.getUserSessionByParentToken.mockResolvedValue(successor); mockStorage.revokeSessionFamily.mockResolvedValue(undefined); const result = await authService.refreshToken(oldPlain); expect(result.success).toBe(false); expect(mockStorage.revokeSessionFamily).toHaveBeenCalledWith('family-1'); expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled(); }); it('route: повтор в grace-окне → 200 и новая пара токенов + cookie', async () => { const app = buildApp(); setupExistingUser(); const oldPlain = 'old-plain-route'; const replaced = makeSession(oldPlain, { id: 10, isReplaced: true }); const successor = makeSession('new-plain-route', { id: 11, createdAt: new Date() }); mockStorage.getUserSessionByToken.mockResolvedValue(replaced); mockStorage.getUserSessionByParentToken.mockResolvedValue(successor); mockStorage.getActiveSessionInFamily.mockResolvedValue(successor); mockStorage.markSessionReplaced.mockResolvedValue(undefined); const res = await request(app) .post('/api/auth/refresh') .send({ refreshToken: oldPlain }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.tokens?.refreshToken).toBeTruthy(); // Сервер выставил новые auth-cookie const setCookie = res.headers['set-cookie']; expect(setCookie).toBeTruthy(); expect(String(setCookie)).toContain('access_token='); expect(String(setCookie)).toContain('refresh_token='); expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); }); it('route: отказ без successor не стирает cookie, при revoked-сессии — стирает', async () => { const app = buildApp(); setupExistingUser(); // Случай 1: replaced без successor → 401 без set-cookie (cookie не трогаем) mockStorage.getUserSessionByToken.mockResolvedValue( makeSession('p1', { id: 10, isReplaced: true }), ); mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined); const res1 = await request(app) .post('/api/auth/refresh') .send({ refreshToken: 'p1' }); expect(res1.status).toBe(401); expect(res1.headers['set-cookie']).toBeUndefined(); expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); // Случай 2: revoked-сессия → 401 со стиранием cookie mockStorage.getUserSessionByToken.mockResolvedValue( makeSession('p2', { id: 12, isRevoked: true }), ); const res2 = await request(app) .post('/api/auth/refresh') .send({ refreshToken: 'p2' }); expect(res2.status).toBe(401); const cleared = String(res2.headers['set-cookie']); expect(cleared).toContain('access_token=;'); expect(cleared).toContain('refresh_token=;'); }); it('отзыв сессии при logout: revokeSession вызывается с plain-токеном (хэширует storage)', async () => { mockStorage.revokeSession.mockResolvedValue(undefined); await authService.logout('some-refresh-token'); expect(mockStorage.revokeSession).toHaveBeenCalledWith('some-refresh-token'); }); }); describe('reset-токены по хэшу', () => { beforeEach(() => { vi.clearAllMocks(); clearLoginAttempts(); }); it('forgot-password: в БД уходит resetPasswordTokenHash и resetPasswordToken=null, plain — только в письмо', async () => { const app = buildApp(); setupExistingUser(); mockStorage.getOrganization.mockResolvedValue({ id: 1, name: 'Тест', displayName: 'Тест' }); const res = await request(app) .post('/api/auth/forgot-password') .send({ email: 'user@example.com' }); expect(res.status).toBe(200); // Plain-токен ушёл в письмо const emailedToken: string = mockEmailService.sendPasswordResetEmail.mock.calls[0][2]; expect(emailedToken).toMatch(/^[0-9a-f]{64}$/); // В БД — только хэш, plain-колонка явно обнулена const updateArg = mockStorage.updateUser.mock.calls.find( (c) => c[1].resetPasswordTokenHash, )?.[1]; expect(updateArg).toBeTruthy(); expect(updateArg.resetPasswordTokenHash).toBe(hashToken(emailedToken)); expect(updateArg.resetPasswordToken).toBeNull(); expect(updateArg.resetPasswordExpires).toBeInstanceOf(Date); }); it('reset-password: поиск пользователя по токену из ссылки, после сброса хэш очищается', async () => { const app = buildApp(); const user = makeUser({ resetPasswordExpires: new Date(Date.now() + 60 * 60 * 1000) }); mockStorage.getUserByResetPasswordToken.mockResolvedValue(user); mockStorage.revokeAllUserSessions.mockResolvedValue(undefined); const res = await request(app) .post('/api/auth/reset-password') .send({ token: 'a'.repeat(64), password: 'NewSecret123!' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); // Токен из ссылки передан в storage как есть — поиск по хэшу внутри storage expect(mockStorage.getUserByResetPasswordToken).toHaveBeenCalledWith('a'.repeat(64)); const updateArg = mockStorage.updateUser.mock.calls.find( (c) => c[1].passwordHash, )?.[1]; expect(updateArg.passwordHash).toBe('hash:NewSecret123!'); expect(updateArg.resetPasswordToken).toBeNull(); expect(updateArg.resetPasswordTokenHash).toBeNull(); expect(updateArg.resetPasswordExpires).toBeNull(); // Все сессии пользователя инвалидируются expect(mockStorage.revokeAllUserSessions).toHaveBeenCalledWith(user.id); }); it('reset-password: невалидный токен не находится → 400', async () => { const app = buildApp(); mockStorage.getUserByResetPasswordToken.mockResolvedValue(undefined); const res = await request(app) .post('/api/auth/reset-password') .send({ token: 'b'.repeat(64), password: 'NewSecret123!' }); expect(res.status).toBe(400); expect(res.body.error).toContain('Неверная или устаревшая ссылка'); expect(mockStorage.updateUser).not.toHaveBeenCalled(); }); it('reset-password: истёкший токен → 400, хэш очищается', async () => { const app = buildApp(); const user = makeUser({ resetPasswordExpires: new Date(Date.now() - 1000) }); mockStorage.getUserByResetPasswordToken.mockResolvedValue(user); const res = await request(app) .post('/api/auth/reset-password') .send({ token: 'c'.repeat(64), password: 'NewSecret123!' }); expect(res.status).toBe(400); expect(res.body.error).toContain('истёк'); const updateArg = mockStorage.updateUser.mock.calls[0][1]; expect(updateArg.resetPasswordTokenHash).toBeNull(); expect(updateArg.resetPasswordToken).toBeNull(); }); });