Files
samreshu_backend/tests/services/auth.service.test.ts
Anton 223feed0e0 feat: синхронизация бэкенда с документацией (AGENT_TASK_BACKEND_SYNC)
- Добвлен @fastify/cookie и настройку httpOnly cookie для refresh token
- Добавлен префикс /api/v1 для auth, profile, tests, admin
- Скорректировано в Login: возвращать user (id, nickname, avatarUrl, role, emailVerified),
  ставить refreshToken в Set-Cookie
- Скорректировано в Logout: Bearer + cookie, пустое тело, 200 + { message }, очищать cookie
- Скорректировано в Refresh: token из cookie, пустое тело, 200 + { accessToken }, Set-Cookie
- Добавлено в getPrivateProfile: поля role и plan
- Скорректировано в Tests: score = количество правильных, ответ { score, totalQuestions, percentage }
- Добавлено в question_cache_meta: поля valid, retryCount, questionsGenerated
- Обновлены тесты
2026-03-06 13:58:34 +03:00

312 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthService } from '../../src/services/auth/auth.service.js';
import {
createMockDb,
selectChain,
insertChain,
updateChain,
deleteChain,
} from '../test-utils.js';
vi.mock('../../src/utils/password.js', () => ({
hashPassword: vi.fn().mockResolvedValue('hashed-password'),
verifyPassword: vi.fn().mockResolvedValue(true),
}));
vi.mock('../../src/utils/jwt.js', () => ({
signAccessToken: vi.fn().mockResolvedValue('access-token'),
signRefreshToken: vi.fn().mockResolvedValue('refresh-token'),
verifyToken: vi.fn(),
isRefreshPayload: vi.fn(),
hashToken: vi.fn((t: string) => `hash-${t}`),
}));
vi.mock('node:crypto', () => ({
randomBytes: vi.fn(() => ({
toString: () => 'abc123',
})),
randomUUID: vi.fn(() => 'uuid-session-1'),
}));
import { hashPassword, verifyPassword } from '../../src/utils/password.js';
import { signAccessToken, signRefreshToken, verifyToken, isRefreshPayload } from '../../src/utils/jwt.js';
describe('AuthService', () => {
let mockDb: ReturnType<typeof createMockDb>;
let authService: AuthService;
beforeEach(() => {
vi.clearAllMocks();
mockDb = createMockDb();
authService = new AuthService(mockDb as never);
});
describe('register', () => {
it('registers a new user when email and nickname are available', async () => {
(mockDb.select as ReturnType<typeof vi.fn>)
.mockReturnValueOnce(selectChain([]))
.mockReturnValueOnce(selectChain([]));
(mockDb.insert as ReturnType<typeof vi.fn>)
.mockReturnValueOnce(insertChain([{ id: 'user-123' }]))
.mockReturnValueOnce(insertChain([]));
const result = await authService.register({
email: 'test@example.com',
password: 'password123',
nickname: 'tester',
});
expect(result.userId).toBe('user-123');
expect(result.verificationCode).toBeDefined();
expect(hashPassword).toHaveBeenCalledWith('password123');
});
it('throws when email is already taken', async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([{ id: 'existing', email: 'test@example.com' }])
);
await expect(
authService.register({
email: 'test@example.com',
password: 'password123',
nickname: 'tester',
})
).rejects.toMatchObject({ code: 'EMAIL_TAKEN' });
});
it('throws when nickname is already taken', async () => {
(mockDb.select as ReturnType<typeof vi.fn>)
.mockReturnValueOnce(selectChain([]))
.mockReturnValueOnce(selectChain([{ id: 'existing', nickname: 'tester' }]));
await expect(
authService.register({
email: 'test@example.com',
password: 'password123',
nickname: 'tester',
})
).rejects.toMatchObject({ code: 'NICKNAME_TAKEN' });
});
});
describe('login', () => {
it('returns user and tokens when credentials are valid', async () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
nickname: 'tester',
avatarUrl: null,
role: 'free',
emailVerifiedAt: null,
passwordHash: 'hashed',
};
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([mockUser])
);
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
insertChain([])
);
const result = await authService.login({
email: 'test@example.com',
password: 'password123',
});
expect(result.user).toBeDefined();
expect(result.user.id).toBe('user-1');
expect(result.user.email).toBe('test@example.com');
expect(result.accessToken).toBe('access-token');
expect(result.refreshToken).toBe('refresh-token');
expect(verifyPassword).toHaveBeenCalledWith('hashed', 'password123');
expect(signAccessToken).toHaveBeenCalled();
expect(signRefreshToken).toHaveBeenCalled();
});
it('throws when user not found', async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([])
);
await expect(
authService.login({ email: 'nonexistent@example.com', password: 'x' })
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('throws when password is wrong', async () => {
vi.mocked(verifyPassword).mockResolvedValueOnce(false);
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([{ id: 'user-1', email: 'test@example.com', passwordHash: 'hashed' }])
);
await expect(
authService.login({ email: 'test@example.com', password: 'wrong' })
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
});
describe('logout', () => {
it('deletes session by refresh token hash', async () => {
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
deleteChain()
);
await authService.logout('some-refresh-token');
expect(mockDb.delete).toHaveBeenCalled();
});
});
describe('refresh', () => {
it('returns new tokens when refresh token is valid', async () => {
vi.mocked(verifyToken).mockResolvedValueOnce({
sub: 'user-1',
sid: 'sid-1',
type: 'refresh',
} as never);
vi.mocked(isRefreshPayload).mockReturnValueOnce(true);
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([{ id: 'sess-1', userId: 'user-1' }])
);
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
deleteChain()
);
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([{ id: 'user-1', email: 'test@example.com' }])
);
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
insertChain([])
);
const result = await authService.refresh({
refreshToken: 'valid-refresh-token',
});
expect(result.accessToken).toBe('access-token');
expect(result.refreshToken).toBe('refresh-token');
});
it('throws when token is not a refresh payload', async () => {
vi.mocked(verifyToken).mockResolvedValueOnce({
sub: 'user-1',
type: 'access',
} as never);
vi.mocked(isRefreshPayload).mockReturnValueOnce(false);
await expect(
authService.refresh({ refreshToken: 'access-token' })
).rejects.toMatchObject({ message: expect.stringContaining('Invalid refresh token') });
});
it('throws when session not found', async () => {
vi.mocked(verifyToken).mockResolvedValueOnce({
sub: 'user-1',
sid: 'sid-1',
type: 'refresh',
} as never);
vi.mocked(isRefreshPayload).mockReturnValueOnce(true);
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([])
);
await expect(
authService.refresh({ refreshToken: 'invalid-or-expired' })
).rejects.toMatchObject({ code: 'INVALID_REFRESH_TOKEN' });
});
});
describe('verifyEmail', () => {
it('throws when verification code is invalid', async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([])
);
await expect(
authService.verifyEmail('user-1', 'WRONG')
).rejects.toMatchObject({ code: 'INVALID_CODE' });
});
it('updates user and deletes code when valid', async () => {
const mockCode = {
id: 'code-1',
userId: 'user-1',
code: 'ABC123',
expiresAt: new Date(Date.now() + 60000),
};
(mockDb.select as ReturnType<typeof vi.fn>)
.mockReturnValueOnce(selectChain([mockCode]))
.mockReturnValueOnce(selectChain([{ id: 'user-1', emailVerifiedAt: null }]));
(mockDb.update as ReturnType<typeof vi.fn>).mockReturnValueOnce(
updateChain([{ id: 'user-1' }])
);
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
deleteChain()
);
await authService.verifyEmail('user-1', 'ABC123');
expect(mockDb.update).toHaveBeenCalled();
expect(mockDb.delete).toHaveBeenCalled();
});
});
describe('forgotPassword', () => {
it('returns empty token when user not found', async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([])
);
const result = await authService.forgotPassword('unknown@example.com');
expect(result.token).toBe('');
expect(result.expiresAt).toBeInstanceOf(Date);
});
it('returns token when user exists', async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([{ id: 'user-1', email: 'test@example.com' }])
);
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
insertChain([])
);
const result = await authService.forgotPassword('test@example.com');
expect(result.token).toBeDefined();
expect(result.token).not.toBe('');
expect(result.expiresAt).toBeInstanceOf(Date);
});
});
describe('resetPassword', () => {
it('throws when token is invalid', async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([])
);
await expect(
authService.resetPassword('invalid-token', 'newPassword123')
).rejects.toMatchObject({ code: 'INVALID_RESET_TOKEN' });
});
it('updates password when token is valid', async () => {
const mockRecord = { id: 'rec-1', userId: 'user-1', expiresAt: new Date(Date.now() + 60000) };
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
selectChain([mockRecord])
);
(mockDb.update as ReturnType<typeof vi.fn>).mockReturnValueOnce(
updateChain([{ id: 'user-1' }])
);
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
deleteChain()
);
await authService.resetPassword('valid-token', 'newPassword123');
expect(hashPassword).toHaveBeenCalledWith('newPassword123');
expect(mockDb.update).toHaveBeenCalled();
expect(mockDb.delete).toHaveBeenCalled();
});
});
});