diff --git a/apps/api/test/e2e/auth.e2e-spec.ts b/apps/api/test/e2e/auth.e2e-spec.ts new file mode 100644 index 00000000..41126a5a --- /dev/null +++ b/apps/api/test/e2e/auth.e2e-spec.ts @@ -0,0 +1,144 @@ +import type { Server } from 'http' +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { INestApplication } from '@nestjs/common' +import request from 'supertest' +import type { Redis } from 'ioredis' + +import { PrismaService } from '../../prisma/prisma.service' +import { createTestApp } from '../helpers/e2e.helpers' + +// Извлекает значение cookie по имени из массива Set-Cookie заголовков +function getCookieValue(setCookie: string | string[], name: string): string | undefined { + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie] + const found = cookies.find((c) => c.startsWith(`${name}=`)) + return found?.split(';')[0].split('=')[1] +} + +describe('Auth (e2e)', () => { + let app: INestApplication + let server: Server + let prisma: PrismaService + let redisClient: Redis + + beforeAll(async () => { + const testApp = await createTestApp() + app = testApp.app + server = app.getHttpServer() as Server + prisma = testApp.prisma + redisClient = testApp.redisClient + }) + + afterAll(async () => { + await redisClient.flushall() + await prisma.$disconnect() + await app.close() + }) + + beforeEach(async () => { + await prisma.user.deleteMany() + await redisClient.flushall() + }) + + // ── POST /auth/register ─────────────────────────────────────────────────── + describe('POST /auth/register', () => { + it('должен зарегистрировать пользователя, вернуть 201 и установить cookies', async () => { + const res = await request(server) + .post('/auth/register') + .send({ email: 'alice@example.com', password: 'P@ssw0rd!', name: 'Alice' }) + .expect(201) + + const setCookie = res.headers['set-cookie'] ?? [] + expect(getCookieValue(setCookie, 'accessToken')).toBeDefined() + expect(getCookieValue(setCookie, 'refreshToken')).toBeDefined() + }) + + it('должен вернуть 409 при повторной регистрации с тем же email', async () => { + await request(server) + .post('/auth/register') + .send({ email: 'alice@example.com', password: 'P@ssw0rd!', name: 'Alice' }) + .expect(201) + + const res = await request(server) + .post('/auth/register') + .send({ email: 'alice@example.com', password: 'P@ssw0rd!', name: 'Alice' }) + .expect(409) + + expect(res.body.message).toBe('Пользователь с таким email уже существует') + }) + + it('должен вернуть 400 при некорректном email', async () => { + await request(server) + .post('/auth/register') + .send({ email: 'not-an-email', password: 'P@ssw0rd!', name: 'Alice' }) + .expect(400) + }) + + it('должен вернуть 400 если пароль короче 6 символов', async () => { + await request(server) + .post('/auth/register') + .send({ email: 'alice@example.com', password: '123', name: 'Alice' }) + .expect(400) + }) + + it('должен вернуть 400 если email не передан', async () => { + await request(server) + .post('/auth/register') + .send({ password: 'P@ssw0rd!', name: 'Alice' }) + .expect(400) + }) + }) + + // ── POST /auth/login ────────────────────────────────────────────────────── + describe('POST /auth/login', () => { + beforeEach(async () => { + // Создаём пользователя перед каждым тестом логина + await request(server) + .post('/auth/register') + .send({ email: 'alice@example.com', password: 'P@ssw0rd!', name: 'Alice' }) + .expect(201) + }) + + it('должен выполнить вход, вернуть 200 и установить cookies', async () => { + const res = await request(server) + .post('/auth/login') + .send({ email: 'alice@example.com', password: 'P@ssw0rd!' }) + .expect(200) + + const setCookie = res.headers['set-cookie'] ?? [] + expect(getCookieValue(setCookie, 'accessToken')).toBeDefined() + expect(getCookieValue(setCookie, 'refreshToken')).toBeDefined() + }) + + it('должен вернуть 404 при входе с несуществующим email', async () => { + const res = await request(server) + .post('/auth/login') + .send({ email: 'nobody@example.com', password: 'P@ssw0rd!' }) + .expect(404) + + expect(res.body.message).toBe('Пользователь не найден') + }) + + it('должен вернуть 404 при входе с неверным паролем', async () => { + const res = await request(server) + .post('/auth/login') + .send({ email: 'alice@example.com', password: 'WrongPass1!' }) + .expect(404) + + expect(res.body.message).toBe('Пользователь не найден') + }) + + it('должен вернуть 400 при некорректном email', async () => { + await request(server) + .post('/auth/login') + .send({ email: 'bad-email', password: 'P@ssw0rd!' }) + .expect(400) + }) + + it('должен вернуть 400 если пароль не передан', async () => { + await request(server) + .post('/auth/login') + .send({ email: 'alice@example.com' }) + .expect(400) + }) + }) +}) diff --git a/apps/api/test/helpers/auth.helpers.ts b/apps/api/test/helpers/auth.helpers.ts index 8ebfcd45..77d0cb62 100644 --- a/apps/api/test/helpers/auth.helpers.ts +++ b/apps/api/test/helpers/auth.helpers.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest' import { ConfigService } from '@nestjs/config' import { JwtService } from '@nestjs/jwt' -import type { Response } from 'express' +import type { Request, Response } from 'express' import { PrismaService } from '../../prisma/prisma.service' import { RedisService } from '../../src/common/redis/redis.service' @@ -29,9 +29,15 @@ export function createPrismaMock() { export function createJwtMock() { const jwtService = { sign: vi.fn(), + verifyAsync: vi.fn(), + decode: vi.fn(), } jwtService.sign.mockReturnValueOnce(ACCESS_TOKEN).mockReturnValueOnce(REFRESH_TOKEN) - return jwtService as unknown as JwtService + return jwtService as unknown as JwtService & { + sign: ReturnType + verifyAsync: ReturnType + decode: ReturnType + } } export function createConfigMock() { @@ -50,7 +56,11 @@ export function createRedisMock() { setRefreshToken: vi.fn().mockResolvedValue(undefined), deleteRefreshToken: vi.fn().mockResolvedValue(undefined), getRefreshToken: vi.fn(), - } as unknown as RedisService + } as unknown as RedisService & { + setRefreshToken: ReturnType + deleteRefreshToken: ReturnType + getRefreshToken: ReturnType + } } export function createResMock() { @@ -58,3 +68,7 @@ export function createResMock() { cookie: vi.fn(), } as unknown as Response } + +export function createReqMock(cookies: Record = {}) { + return { cookies } as unknown as Request +} diff --git a/apps/api/test/unit/auth/auth.service.spec.ts b/apps/api/test/unit/auth/auth.service.spec.ts index 34e9ce59..cd176b47 100644 --- a/apps/api/test/unit/auth/auth.service.spec.ts +++ b/apps/api/test/unit/auth/auth.service.spec.ts @@ -1,4 +1,8 @@ -import { ConflictException, NotFoundException } from '@nestjs/common' +import { + ConflictException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthService } from '../../../src/auth/auth.service' @@ -7,8 +11,10 @@ import { createJwtMock, createRedisMock, createResMock, + createReqMock, createPrismaMock, makeTokens, + REFRESH_TOKEN, } from '../../helpers/auth.helpers' // ── argon2 mock ────────────────────────────────────────────────────────────── @@ -34,6 +40,8 @@ const STORED_USER = { id: 'user-id-1', password: 'hashed_password' } describe('AuthService', () => { let service: AuthService let prisma: ReturnType + let redis: ReturnType + let jwt: ReturnType let res: ReturnType beforeEach(() => { @@ -42,9 +50,9 @@ describe('AuthService', () => { prisma = createPrismaMock() res = createResMock() - const jwt = createJwtMock() + jwt = createJwtMock() const config = createConfigMock() - const redis = createRedisMock() + redis = createRedisMock() service = new AuthService(prisma, config, jwt, redis) }) @@ -130,4 +138,104 @@ describe('AuthService', () => { ) }) }) + + // ── refresh ─────────────────────────────────────────────────────────────── + describe('refresh', () => { + it('должен выдать новые токены при валидном refresh-токене', async () => { + const req = createReqMock({ refreshToken: REFRESH_TOKEN }) + jwt.verifyAsync.mockResolvedValue({ id: 'user-id-1' }) + redis.getRefreshToken.mockResolvedValue(REFRESH_TOKEN) + prisma.user.findUnique.mockResolvedValue({ id: 'user-id-1' }) + + await service.refresh(req, res) + + expect(jwt.verifyAsync).toHaveBeenCalledWith(REFRESH_TOKEN) + expect(redis.getRefreshToken).toHaveBeenCalledWith('user-id-1') + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'user-id-1' }, + select: { id: true }, + }) + // sign вызывается дважды: accessToken + refreshToken + expect(jwt.sign).toHaveBeenCalledTimes(2) + // новый refresh-токен сохраняется в Redis + expect(redis.setRefreshToken).toHaveBeenCalledWith('user-id-1', REFRESH_TOKEN) + expect(res.cookie).toHaveBeenCalledTimes(2) + }) + + it('должен выбросить UnauthorizedException если cookie refreshToken отсутствует', async () => { + const req = createReqMock({}) + + await expect(service.refresh(req, res)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh(req, res)).rejects.toThrow( + 'Недействительный refresh-токен', + ) + }) + + it('должен выбросить UnauthorizedException если токена нет в Redis', async () => { + const req = createReqMock({ refreshToken: REFRESH_TOKEN }) + jwt.verifyAsync.mockResolvedValue({ id: 'user-id-1' }) + redis.getRefreshToken.mockResolvedValue(null) + + await expect(service.refresh(req, res)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh(req, res)).rejects.toThrow( + 'Недействительный refresh-токен', + ) + }) + + it('должен выбросить UnauthorizedException если токен не совпадает с сохранённым', async () => { + const req = createReqMock({ refreshToken: REFRESH_TOKEN }) + jwt.verifyAsync.mockResolvedValue({ id: 'user-id-1' }) + redis.getRefreshToken.mockResolvedValue('other_token') + + await expect(service.refresh(req, res)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh(req, res)).rejects.toThrow( + 'Недействительный refresh-токен', + ) + }) + + it('должен выбросить NotFoundException если пользователь удалён из БД', async () => { + const req = createReqMock({ refreshToken: REFRESH_TOKEN }) + jwt.verifyAsync.mockResolvedValue({ id: 'user-id-1' }) + redis.getRefreshToken.mockResolvedValue(REFRESH_TOKEN) + prisma.user.findUnique.mockResolvedValue(null) + + await expect(service.refresh(req, res)).rejects.toThrow(NotFoundException) + await expect(service.refresh(req, res)).rejects.toThrow('Пользователь не найден') + }) + }) + + // ── logout ──────────────────────────────────────────────────────────────── + describe('logout', () => { + it('должен удалить refresh-токен из Redis и очистить cookies', async () => { + const req = createReqMock({ refreshToken: REFRESH_TOKEN }) + jwt.decode.mockReturnValue({ id: 'user-id-1' }) + + const result = await service.logout(req, res) + + expect(jwt.decode).toHaveBeenCalledWith(REFRESH_TOKEN) + expect(redis.deleteRefreshToken).toHaveBeenCalledWith('user-id-1') + expect(res.cookie).toHaveBeenCalledTimes(2) + expect(res.cookie).toHaveBeenCalledWith( + 'refreshToken', + '', + expect.objectContaining({ expires: new Date(0) }), + ) + expect(res.cookie).toHaveBeenCalledWith( + 'accessToken', + '', + expect.objectContaining({ expires: new Date(0) }), + ) + expect(result).toEqual({ message: 'Пользователь успешно вышел', success: true }) + }) + + it('должен корректно завершиться если cookie refreshToken отсутствует', async () => { + const req = createReqMock({}) + + const result = await service.logout(req, res) + + expect(jwt.decode).not.toHaveBeenCalled() + expect(redis.deleteRefreshToken).not.toHaveBeenCalled() + expect(result).toEqual({ message: 'Пользователь успешно вышел', success: true }) + }) + }) }) diff --git a/apps/api/vitest.config.e2e.ts b/apps/api/vitest.config.e2e.ts index 5931eaaa..467442fb 100644 --- a/apps/api/vitest.config.e2e.ts +++ b/apps/api/vitest.config.e2e.ts @@ -10,5 +10,7 @@ export default defineConfig({ environment: 'node', globals: true, setupFiles: ['../vitest.setup.ts'], + // e2e тесты делят одну БД и Redis — запускаем файлы последовательно + fileParallelism: false, }, })