Шаг 2.1 плана production-готовности: - единый 401 при любом отказе логина + constant-time bcrypt - forgot-password: идентичный ответ независимо от существования email - sanitizeReturnTo (open redirect fix в /api/documents/generate-link) - 10 неудачных попыток → блок 15 мин (in-memory, аудит) - DATABASE_SSL_REJECT_UNAUTHORIZED env (дефолт true) - доделка 0.7: статичные тексты в llm-providers/rag/finance-di2 - 8 новых тестов (104/104)
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
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<string, unknown> = {}) {
|
||
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();
|
||
});
|
||
});
|