- Добвлен @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
- Обновлены тесты
312 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|