Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions apps/api/test/e2e/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
20 changes: 17 additions & 3 deletions apps/api/test/helpers/auth.helpers.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<typeof vi.fn>
verifyAsync: ReturnType<typeof vi.fn>
decode: ReturnType<typeof vi.fn>
}
}

export function createConfigMock() {
Expand All @@ -50,11 +56,19 @@ 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<typeof vi.fn>
deleteRefreshToken: ReturnType<typeof vi.fn>
getRefreshToken: ReturnType<typeof vi.fn>
}
}

export function createResMock() {
return {
cookie: vi.fn(),
} as unknown as Response
}

export function createReqMock(cookies: Record<string, string> = {}) {
return { cookies } as unknown as Request
}
114 changes: 111 additions & 3 deletions apps/api/test/unit/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -7,8 +11,10 @@ import {
createJwtMock,
createRedisMock,
createResMock,
createReqMock,
createPrismaMock,
makeTokens,
REFRESH_TOKEN,
} from '../../helpers/auth.helpers'

// ── argon2 mock ──────────────────────────────────────────────────────────────
Expand All @@ -34,6 +40,8 @@ const STORED_USER = { id: 'user-id-1', password: 'hashed_password' }
describe('AuthService', () => {
let service: AuthService
let prisma: ReturnType<typeof createPrismaMock>
let redis: ReturnType<typeof createRedisMock>
let jwt: ReturnType<typeof createJwtMock>
let res: ReturnType<typeof createResMock>

beforeEach(() => {
Expand All @@ -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)
})
Expand Down Expand Up @@ -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 })
})
})
})
2 changes: 2 additions & 0 deletions apps/api/vitest.config.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ export default defineConfig({
environment: 'node',
globals: true,
setupFiles: ['../vitest.setup.ts'],
// e2e тесты делят одну БД и Redis — запускаем файлы последовательно
fileParallelism: false,
},
})
Loading