javascript

Настройка Express 5 для продакшна в 2025 году. Часть 2

  • понедельник, 13 октября 2025 г. в 00:00:05
https://habr.com/ru/companies/timeweb/articles/950022/

Эта статья поможет вам создать приложение Express 5 с поддержкой TypeScript.

Вы настроите готовый к продакшну проект с помощью различных инструментов для линтинга, тестирования и проверки типов. В случае, если вы новичок в REST API, не волнуйтесь, эта статья также включает объяснения основных концепций, которые следует знать, таких как маршрутизация (роутинг) и аутентификация.

Настоятельно рекомендую писать код вместе со мной. Мы будем использовать подход "Разработка через тестирование" (test-driven development, TDD) для создания REST API, который может стать основой вашего следующего приложения Express.

Прим. пер.: в коде оригинальной статьи встречаются устаревшие (deprecated) свойства и методы. Также некоторые оригинальные тесты работают нестабильно. Я позволил себе внести необходимые коррективы. Однако, учитывая объем материала, я вполне мог что-то упустить, поэтому вот ссылка на мой вариант полностью работоспособного кода приложения.

Это вторая часть туториала.

❯ Аутентификация

Большинству приложений требуется какая-то форма аутентификации. В этом туториале мы будем использовать токены JWT в куки для аутентификации запросов. Пользователи будут использовать классическую комбинацию email и пароля для аутентификации.

Однако, важно отметить, что пароли устарели. Перестаньте собирать и хранить пароли. Пароли являются слабыми, поскольку они могут копироваться, похищаться и взламываться грубой силой (brute force). Используйте passkeys для сильного логина и email OTP (one-time password - одноразовый пароль) только в качестве резерва.

Прим. пер.: у passkeys есть свои недостатки, поэтому утверждение о том, что пароли устарели, является довольно спорным.

Все, что вы узнаете об обработке токенов JWT и куки будет полезным, независимо от метода аутентификации.

Аутентификация будет работать через куки. При регистрации или входе в систему пользователя, куки добавляется в ответ, отправляемый клиенту. Браузер автоматически отправляет куки в каждом запросе к серверу. Сервер читает куки и использует их для аутентификации пользователя. Кроме этого, роут register будет создавать профиль пользователя и сохранять его в БД. Роут logout будет отправлять ответ, указывающий браузеру удалить куки.

❯ Вход в систему

Начнем с роута login. Создаем файл src/features/user-authentication/user-authentication.test.ts:

import { createId } from '@paralleldrive/cuid2';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';

import { buildApp } from '~/app.js';

import { createPopulatedUserProfile } from '../user-profile/user-profile-factories.js';
import {
  deleteUserProfileFromDatabaseById,
  saveUserProfileToDatabase,
} from '../user-profile/user-profile-model.js';
import { hashPassword } from './user-authentication-helpers.js';

async function setup({ password = 'password' }: { password?: string } = {}) {
  const app = buildApp();

  const userProfile = await saveUserProfileToDatabase(
    createPopulatedUserProfile({
      hashedPassword: await hashPassword(password),
    }),
  );

  onTestFinished(async () => {
    await deleteUserProfileFromDatabaseById(userProfile.id);
  });

  return { app, userProfile };
}

describe('/api/v1/login', () => {
  test('дано: валидные данные существующего пользователя, ожидается: возврат статуса 200 и установка JWT куки', async () => {
    const password = createId();
    const { app, userProfile } = await setup({ password });

    const actual = await request(app)
      .post('/api/v1/login')
      .send({ email: userProfile.email, password })
      .expect(200);

    expect(actual.body).toEqual({ message: 'Logged in successfully' });
    // Проверяем установку HTTP-only куки. По каким-то причинам
    // supertest типизирует куки как строку, хотя это массив
    const cookies = actual.headers['set-cookie'] as unknown as string[];
    expect(cookies).toBeDefined();
    expect(cookies.some(cookie => cookie.includes('jwt='))).toEqual(true);
  });

  test('дано: валидные данные несуществующего пользователя, ожидается: возврат статуса 401', async () => {
    const { app } = await setup();

    const { body: actual } = await request(app)
      .post('/api/v1/login')
      .send({ email: 'non-existing@test.com', password: 'password' })
      .expect(401);
    const expected = { message: 'Invalid credentials' };

    expect(actual).toEqual(expected);
  });

  test('дано: валидный email, но неверный пароль существующего пользователя, ожидается: возврат статуса 401', async () => {
    const { app, userProfile } = await setup();

    const actual = await request(app)
      .post('/api/v1/login')
      .send({ email: userProfile.email, password: 'invalid password' })
      .expect(401);

    expect(actual.body).toEqual({ message: 'Invalid credentials' });
  });

  test('дано: невалидные данные, ожидается: возврат статуса 400', async () => {
    const { app } = await setup();

    const { body: actual } = await request(app)
      .post('/api/v1/login')
      .send({})
      .expect(400);
    const expected = {
      message: 'Bad Request',
      errors: [
        {
          code: 'invalid_type',
          expected: 'string',
          message: 'Invalid input: expected string, received undefined',
          path: ['email'],
        },
        {
          code: 'invalid_type',
          expected: 'string',
          message: 'Invalid input: expected string, received undefined',
          path: ['password'],
        },
      ],
    };

    expect(actual).toEqual(expected);
  });
});

Сначала мы определяем функцию setup, которая создает приложение и пользователя с хэшированным паролем и записывает его в БД. Обработчик onTestFinished удаляет профиль пользователя после завершения тестов.

Функции hashPassword пока нет, но скоро мы ее создадим. Как правило, при TDD нормальной практикой считается использование несуществующих функций, поскольку к ним также можно рекурсивно применить TDD. Обычно, сначала создается пустая версия, чтобы проходили импорты, а уже затем реализуется поведение.

Затем определяется тест для роута /api/v1/login.

Сначала тестируется счастливый путь (happy path), когда пользователь существует и данные являются валидными.

Затем обрабатывается 4 тест-кейса:

  1. Валидные данные существующего пользователя.

  2. Валидные данные несуществующего пользователя.

  3. Неверный пароль существующего пользователя.

  4. Невалидные данные в теле запроса.

Каждый тест проверяет правильный статус-код HTTP и корректное тело ответа.

Для реализации роута и его тестов необходимо несколько вспомогательных функций.

Нам нужна функция для хэширования пароля, еще одна для сравнения пароля с хэшем, функция для генерации токена JWT для пользователя и функция для установки куки JWT. Кроме того, нам нужна функция проверки валидности токена и функция для извлечения токена JWT из куки запроса. Напишем тесты для этих функций.

Функции hashPassword и getIsPasswordValid будут использоваться вместе, поэтому и тестировать их имеет смысл совместно:

// src/features/user-authentication/user-authentication-helpers.test.ts
import { createId } from '@paralleldrive/cuid2';
import { describe, expect, test } from 'vitest';

import {
  getIsPasswordValid,
  hashPassword,
} from './user-authentication-helpers.js';

describe('getIsPasswordValid() & hashPassword()', () => {
  test('дано: пароль, ожидается: хэшированный пароль', async () => {
    const password = createId();
    const hashedPassword = await hashPassword(password);

    const actual = await getIsPasswordValid(password, hashedPassword);
    const expected = true;

    expect(actual).toEqual(expected);
  });
});

Сначала мы используем hashPassword() для хэширования пароля, затем - getIsPasswordValid() для его валидации.

Пароли можно хэшировать с помощью библиотеки bcrypt. Устанавливаем ее:

npm i bcrypt && npm i -D @types/bcrypt

Реализуем обе функции:

// src/features/user-authentication/user-authentication-helpers.ts
import bcrypt from 'bcrypt';

/**
 * Хэширует пароль.
 *
 * @param password Пароль для хэширования.
 * @returns Хэшированный пароль.
 */
export async function hashPassword(password: string) {
  return await bcrypt.hash(password, 10);
}

/**
 * Сравнивает пароль с хэшированным паролем.
 *
 * @param password Пароль для сравнения.
 * @param hashedPassword Хэшированный пароль для сравнения.
 * @returns true, если пароль валиден, иначе false.
 */
export async function getIsPasswordValid(
  password: string,
  hashedPassword: string,
) {
  return await bcrypt.compare(password, hashedPassword);
}

Теперь тесты должны проходить.

Добавляем тест для функции генерации токена JWT:

// src/features/user-authentication/user-authentication-helpers.test.js
// Другие импорты...
import {
  generateJwtToken,
  getIsPasswordValid,
  hashPassword,
} from './user-authentication-helpers.js';

// Другие тесты...

describe('generateJwtToken()', () => {
  test('дано: профиль пользователя, ожидается: токен JWT', () => {
    const userProfile = {
      id: 'ozlnvq593weqj51j5p69adul',
      email: 'Jamarcus.Haag44@hotmail.com',
      name: 'Dr. Philip Lindgren',
      createdAt: new Date('2022-09-25T20:03:54.119Z'),
      updatedAt: new Date('2025-01-29T11:25:38.342Z'),
      hashedPassword: 'b6d93ffb-8093-4940-bd1f-c9e8020851e4',
    };
    const jwtToken = generateJwtToken(userProfile);

    const actual = jwtToken.startsWith('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9');
    const expected = true;

    expect(actual).toEqual(expected);
  });
});

В этом тесте мы создаем простой профиль пользователя, генерируем токен для него и проверяем, что токен начинается с определенной строки. Это строка может различаться в зависимости от среды выполнения кода и переменной JWT_SECRET, которую мы установим при реализации следующей функции.

Нам потребуется еще несколько пакетов:

npm i dotenv jsonwebtoken && npm i -D @types/jsonwebtoken

Реализуем функцию:

// src/features/user-authentication/user-authentication-helpers.ts
import bcrypt from 'bcrypt';
import dotenv from 'dotenv';
import jwt from 'jsonwebtoken';

import type { UserProfile } from '~/generated/prisma/index.js';

dotenv.config();

// Другие функции...

/**
 * Генерирует токен JWT. Не забудьте определить process.env.JWT_SECRET.
 *
 * @param userProfile Профиль пользователя, для которого генерируется токен.
 * @returns Сгенерированный токен JWT.
 */
export function generateJwtToken(userProfile: UserProfile) {
  const tokenPayload = {
    id: userProfile.id,
    email: userProfile.email,
  };
  return jwt.sign(tokenPayload, process.env.JWT_SECRET as string, {
    expiresIn: 60 * 60 * 24 * 365, // 1 год
  });
}

dotenv.config() загружает переменные среды из файла .env в process.env.

Добавляем в .env переменную JWT_SECRET:

JWT_SECRET=your-jwt-secret

Функция generateJwtToken принимает профиль пользователя, извлекает из него id и email и создает токен JWT на их основе. Она подписывает токен секретом из среды окружения и устанавливает срок жизни токена в 1 год.

Теперь тест должен проходить.

Последняя функция, которую необходимо создать перед реализацией роута, - функция для установки куки JWT. Для этой функции не нужны тесты, поскольку для ее юнит-тестирования придется мокать (mock) объект Response Express, и тестироваться будет в основном этот мок, а не функция. Такие функции обычно тестируются с помощью интеграционных тестов.

// src/features/user-authentication/user-authentication-helpers.ts
// Другие импорты...
import type { Response } from 'express';
import jwt from 'jsonwebtoken';

// Другие функции...

export const JWT_COOKIE_NAME = 'jwt';

/**
 * Устанавливает куки JWT.
 *
 * @param response Объект ответа для установки куки.
 * @param token Токен JWT для установки.
 */
export function setJwtCookie(response: Response, token: string) {
  response.cookie(JWT_COOKIE_NAME, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production', // используем безопасные куки в продакшне
    sameSite: 'strict',
  });
}

Настройки куки:

  • httpOnly - куки недоступна с помощью JS, что защищает от межсайтового скриптинга (cross-site scripting, XSS), предотвращая чтение токена "злыми" скриптами

  • secure - куки отправляется только по HTTPS, когда NODE_ENV установлена в production

  • sameSite - куки ограничивается установившим ее сайтом, что предотвращает ее отправку с запросами, инициированными сторонними сервисами

Теперь мы можем реализовать роут /api/v1/login:

// src/features/user-authentication/user-authentication-controller.ts
import type { Request, Response } from 'express';
import { z } from 'zod';

import { validateBody } from '~/middleware/validate.js';

import {
  retrieveUserProfileFromDatabaseByEmail,
} from '../user-profile/user-profile-model.js';
import {
  generateJwtToken,
  getIsPasswordValid,
  setJwtCookie,
} from './user-authentication-helpers.js';

export async function login(request: Request, response: Response) {
  // Валидируем тело запроса на наличие валидного email и пароля
  // из 8 символов, минимум
  const body = await validateBody(
    z.object({
      email: z.email(),
      password: z.string().min(8),
    }),
    request,
    response,
  );

  // Пытаемся найти пользователя в БД по email
  const user = await retrieveUserProfileFromDatabaseByEmail(body.email);

  if (user) {
    const isPasswordValid = await getIsPasswordValid(
      body.password,
      user.hashedPassword,
    );

    if (isPasswordValid) {
      // Генерируем токен JWT, устанавливаем HTTP-only куки и возвращаем
      // статус 200 и сообщение об успехе
      const token = generateJwtToken(user);
      setJwtCookie(response, token);
      response.status(200).json({ message: 'Logged in successfully' });
    } else {
      // Если пароль невалиден, возвращаем статус 401 и сообщение об ошибке
      response.status(401).json({ message: 'Invalid credentials' });
    }
  } else {
    // Если пользователь не найден, возвращаем ошибку Unauthorized
    response.status(401).json({ message: 'Invalid credentials' });
  }
}

Мы определяем асинхронную функцию login для обработки аутентификации пользователей. Функция начинается с валидации тела входящего запроса с помощью посредника validateBody со схемой Zod. Эта схема проверяет, что тело содержит валидный email и пароль, состоящий как минимум из 8 символов.

После валидации функция пытается извлечь пользователя из БД по email путем вызова retrieveUserProfileFromDatabaseByEmail(). Если пользователь найден, функция проверяет пароль путем его сравнения с сохраненным хэшем с помощью функции getIsPasswordValid.

Если пароль верный, функция генерирует токен JWT с помощью generateJwtToken(), устанавливает этот токен как HTTP-only куки в ответ с помощью setJwtCookie() и, наконец, отправляет ответ со статусом 200 и сообщением об успехе. Если пользователь не найден или указан неверный пароль, функция возвращает статус 401 с сообщением "Invalid credentials".

Тесты все еще падают, поскольку роут не добавлен в роутер.

// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';

import { asyncHandler } from '~/utils/async-handler.js';

import { login } from './user-authentication-controller.js';

const router = Router();

router.post('/login', asyncHandler(login));

export { router as userAuthenticationRoutes };

Роутер также должен быть добавлен в apiV1Router:

// src/routes.ts
import { Router } from 'express';

import { healthCheckRoutes } from '~/features/health-check/health-check-routes.js';
import { userAuthenticationRoutes } from '~/features/user-authentication/user-authentication-routes.js';

export const apiV1Router = Router();

apiV1Router.use('/health-check', healthCheckRoutes);
apiV1Router.use(userAuthenticationRoutes);

Обратите внимание на отсутствие сегмента /authentication. Это объясняется тем, что мы хотим, чтобы эти роуты были доступными на верхнем уровне API через /login, /register и /logout.

Теперь тесты должны проходить.

❯ Регистрация

Обычно, начинают с реализации роута регистрации, но роут логина проще, поэтому мы начали с него.

Добавляем тесты для роута регистрации:

// src/features/user-authentication/user-authentication.test.ts
describe('/api/v1/register', () => {
  test('дано: валидные данные для регистрации, ожидается: создание пользователя и возврат статуса 201', async () => {
    const app = buildApp();
    const email = 'test@example.com';
    const password = 'password123';

    const { body: actual } = await request(app)
      .post('/api/v1/register')
      .send({ email, password })
      .expect(201);

    expect(actual).toEqual({ message: 'User registered successfully' });

    // Проверяем запись пользователя в БД
    const createdUser = await retrieveUserProfileFromDatabaseByEmail(email);
    expect(createdUser).toBeDefined();
    expect(createdUser?.email).toEqual(email);

    // Очистка
    if (createdUser) {
      await deleteUserProfileFromDatabaseById(createdUser.id);
    }
  });

  test('дано: email, который уже существует, ожидается: возврат статуса 409', async () => {
    const password = createId();
    const { app, userProfile } = await setup({ password });

    const { body: actual } = await request(app)
      .post('/api/v1/register')
      .send({ email: userProfile.email, password: 'newpassword123' })
      .expect(409);

    expect(actual).toEqual({ message: 'User already exists' });
  });

  test('дано: невалидные данные для регистрации, ожидается: возврат статуса 400', async () => {
    const app = buildApp();

    const { body: actual } = await request(app)
      .post('/api/v1/register')
      .send({})
      .expect(400);

    expect(actual).toEqual({
      message: 'Bad Request',
      errors: [
        {
          code: 'invalid_type',
          expected: 'string',
          message: 'Invalid input: expected string, received undefined',
          path: ['email'],
        },
        {
          code: 'invalid_type',
          expected: 'string',
          message: 'Invalid input: expected string, received undefined',
          path: ['password'],
        },
      ],
    });
  });
});

Сначала тест проверяет передачу валидных данных для регистрации - нового email и пароля. Затем создается новый пользователь и возвращается статус 201 с сообщением об успехе.

Второй тест проверяет, что попытка регистрации аккаунта с email, который уже использовался, возвращает статус 409 с сообщением об ошибке, что предотвращает дублирование аккаунтов.

Третий тест проверяет, что конечная точка возвращает статус 400 и правильно обрабатывает невалидные данные для регистрации.

У нас есть все необходимое для реализации роута регистрации:

// src/features/user-authentication/user-authentication-controller.ts
// Другие импорты...
import {
  retrieveUserProfileFromDatabaseByEmail,
  saveUserProfileToDatabase,
} from '../user-profile/user-profile-model.js';
import {
  generateJwtToken,
  getIsPasswordValid,
  hashPassword,
  setJwtCookie,
} from './user-authentication-helpers.js';

// Другие обработчики...

export async function register(request: Request, response: Response) {
  // Валидируем тело запроса на наличие валидного email и пароля
  // из 8 символов, минимум
  const body = await validateBody(
    z.object({
      email: z.email(),
      password: z.string().min(8),
    }),
    request,
    response,
  );

  // Проверяем наличие пользователя с этим email
  const existingUser = await retrieveUserProfileFromDatabaseByEmail(body.email);

  if (existingUser) {
    response.status(409).json({ message: 'User already exists' });
  } else {
    // Хэшируем пароль и создаем профиль пользователя
    const hashedPassword = await hashPassword(body.password);
    const user = await saveUserProfileToDatabase({
      email: body.email,
      hashedPassword,
    });

    const token = generateJwtToken(user);
    setJwtCookie(response, token);

    response.status(201).json({ message: 'User registered successfully' });
  }
}

Мы валидируем email и пароль пользователя, как обычно, и проверяем, что пользователь еще не регистрировался.

Далее, мы хэшируем пароль и создаем профиль пользователя. Затем генерируем токен JWT, устанавливаем его как HTTP-only куки и отправляем статус 201 с сообщением об успехе.

Обратите внимание, что мы не выбрасываем ошибку 400 явно. Это связано с тем, что посредник validateBody() сам выбрасывает такую ошибку, если тело запроса является невалидным.

// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';

import { asyncHandler } from '~/utils/async-handler.js';

import { login, register } from './user-authentication-controller.js';

const router = Router();

router.post('/login', asyncHandler(login));
router.post('/register', asyncHandler(register));

export { router as userAuthenticationRoutes };

Теперь тесты должны проходить.

❯ Выход из системы

Для проверки выхода из системы требуется только один тест, поскольку все, что нужно сделать роуту, - указать браузеру удалить куки JWT:

// src/features/user-authentication/user-authentication.test.ts
describe('/api/v1/logout', () => {
  test('дано: любой запрос POST, ожидается: очистка JWT куки и возврат статуса 200', async () => {
    const { app } = await setup();

    const response = await request(app).post('/api/v1/logout').expect(200);

    expect(response.body).toEqual({ message: 'Logged out successfully' });

    // Проверяем очистку куки
    const cookies = response.headers['set-cookie'] as unknown as string[];
    expect(cookies).toBeDefined();
    expect(cookies).toEqual([
      'jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict',
    ]);
  });
});

Для реализации роута выхода из системы требуется еще одна вспомогательная функция:

// src/features/user-authentication/user-authentication-helpers.ts
/**
 * Модифицирует ответ, указываю браузеру удалить куки JWT.
 *
 * @param response Объект ответа для очистки куки.
 */
export function clearJwtCookie(response: Response) {
  response.clearCookie(JWT_COOKIE_NAME, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
  });
}

Теперь мы можем реализовать сам роут:

// src/features/user-authentication/user-authentication-controller.ts
// Другие импорты...
import {
  clearJwtCookie,
  generateJwtToken,
  getIsPasswordValid,
  hashPassword,
  setJwtCookie,
} from './user-authentication-helpers.js';

// Другие обработчики...

export async function logout(request: Request, response: Response) {
  clearJwtCookie(response);

  response.status(200).json({ message: 'Logged out successfully' });
}

Добавляем его в роутер:

// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';

import { asyncHandler } from '~/utils/async-handler.js';

import { login, logout, register } from './user-authentication-controller.js';

const router = Router();

router.post('/login', asyncHandler(login));
router.post('/register', asyncHandler(register));
router.post('/logout', asyncHandler(logout));

export { router as userAuthenticationRoutes };

Теперь тесты должны проходить.

❯ Посредник аутентификации

Еще одна необходимая функция, связанная с аутентификацией, - это посредник для защиты роутов от доступа неаутентифицированных пользователей.

Нам не нужны тесты для этой функции, поскольку этот посредник:

  • может тестироваться только с помощью моков

  • будет тестироваться в интеграционных тестах

Если хотите, можете самостоятельно написать тест для функции isTokenValid.

// src/features/user-authentication/user-authentication-helpers.ts
// Другие импорты...
import type { Request, Response } from 'express';

/**
 * Проверяет валидность токена.
 *
 * @param token Токен для проверки.
 * @returns true, если токен валиден, иначе false.
 */
const isTokenValid = (token: jwt.JwtPayload | string) => {
  if (
    typeof token === 'object' &&
    token !== null &&
    'id' in token &&
    'email' in token
  ) {
    return true;
  }

  return false;
};

/**
 * Извлекает токен JWT из куки.
 *
 * @param request Объект запроса, содержащий куки.
 * @returns Токен JWT из куки.
 */
export function getJwtTokenFromCookie(request: Request) {
  const token = request.cookies[JWT_COOKIE_NAME];

  if (!token) {
    throw new Error('No token found');
  }

  const decodedToken = jwt.verify(token, process.env.JWT_SECRET as string);

  if (isTokenValid(decodedToken)) {
    return decodedToken;
  }

  throw new Error('Invalid token payload');
}

Функция isTokenValid проверяет, что декодированный токен имеет правильную структуру (объект с полями id и email).

Функция getJwtTokenFromCookie извлекает токен JWT из куки запроса по предопределенному названию куки (JWT_COOKIE_NAME). Далее, токен проверяется с помощью секрета, валидируется с помощью isTokenValid() и, если все хорошо, возвращается декодированный токен. Если токен невалиден или отсутствует, выбрасывается ошибка.

Теперь можно реализовать посредника:

// src/middleware/require-authentication.ts
import type { Request, Response } from 'express';

import { getJwtTokenFromCookie } from '~/features/user-authentication/user-authentication-helpers.js';

/**
 * Извлекает полезную нагрузку из токена JWT.
 * Выбрасывает ошибку, если токен отсутствует или невалиден.
 *
 * @param request Объект запроса для извлечения токена.
 * @returns Полезная нагрузка токена, содержащая ID и email пользователя.
 */
export function requireAuthentication(request: Request, response: Response) {
  try {
    return getJwtTokenFromCookie(request);
  } catch {
    throw response.status(401).json({ message: 'Unauthorized' });
  }
}

Этот посредник может использоваться следующим образом:

// temp-require-authentication-example.ts
import express from 'express';
import cookieParser from 'cookie-parser';

import { requireAuthentication } from '../middleware/require-authentication.js';

const app = express();
app.use(cookieParser()); // подключаем cookie-parser

app.get('/protected/profile', async (request, response, next) => {
  try {
    // Получаем полезную нагрузку токена
    const { id, email } = requireAuthentication(request, response);

    // Используем ID и email аутентифицированного пользователя в ответе
    response.status(200).json({
      message: `Hello, ${email}! Your user ID is ${id}`,
      userId: id,
    });
  } catch (error) {
    next(error);
  }
});

❯ CRUD роуты для профилей пользователей

Реализуем целую фичу.

Остался один важный вопрос: как получить аутентифицированного пользователя в тестах? Скоро вы это узнаете.

// src/features/user-profile/user-profile.test.ts
import type { UserProfile } from '@prisma/client';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';

import { buildApp } from '~/app.js';

import {
  generateJwtToken,
  JWT_COOKIE_NAME,
} from '../user-authentication/user-authentication-helpers.js';
import { createPopulatedUserProfile } from './user-profile-factories.js';
import {
  deleteUserProfileFromDatabaseById,
  saveUserProfileToDatabase,
} from './user-profile-model.js';

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

async function setup(numberOfProfiles = 1) {
  const app = buildApp();

  const profiles: UserProfile[] = [];
  for (let i = 0; i < numberOfProfiles; i += 1) {
    const profile = await saveUserProfileToDatabase(
      createPopulatedUserProfile(),
    );
    profiles.push(profile);
    // Искусственная задержка для обеспечения уникальных временных меток createdAt
    await sleep(100);
  }

  const token = generateJwtToken(profiles[0]!);

  onTestFinished(async () => {
    try {
      await Promise.all(
        profiles.map(profile => deleteUserProfileFromDatabaseById(profile.id)),
      );
    } catch {
      // Нам нужен перехват ошибок для обработки тестов, удаляющих профили пользователей.
      // Если тест падает и код реализации не удаляет профили пользователей,
      // нам необходимо удалить их в блоке try.
      // Если тест проходит и код реализации удаляет профили пользователей,
      // эта очистка выбрасывает исключение
    }
  });

  return {
    app,
    token,
    profiles: profiles.toSorted(
      (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
    ),
  };
}

describe('/api/v1/user-profiles', () => {
  describe('/', () => {
    describe('GET', () => {
      test('дано: неаутентифицированный запрос, ожидается: возврат статуса 401', async () => {
        const { app } = await setup();

        const { status: actual } = await request(app).get(
          '/api/v1/user-profiles',
        );
        const expected = 401;

        expect(actual).toEqual(expected);
      });

      test('дано: существует несколько профилей, ожидается: возврат статуса 200 с пагинированными профилями', async () => {
        const { app, profiles, token } = await setup(3);
        const [first, second] = profiles as [UserProfile, UserProfile];

        const actual = await request(app)
          .get('/api/v1/user-profiles')
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .query({ page: 1, pageSize: 2 })
          .expect(200);
        const expected = [
          {
            id: first.id,
            email: first.email,
            name: first.name,
            hashedPassword: first.hashedPassword,
            createdAt: first.createdAt.toISOString(),
            updatedAt: first.updatedAt.toISOString(),
          },
          {
            id: second.id,
            email: second.email,
            name: second.name,
            hashedPassword: second.hashedPassword,
            createdAt: second.createdAt.toISOString(),
            updatedAt: second.updatedAt.toISOString(),
          },
        ];

        expect(actual.body).toHaveLength(2);
        expect(actual.body).toEqual(expected);
      });

      test('дано: переданы параметры поисковой строки, ожидается: возврат статуса 200 с профилями запрашиваемой страницы', async () => {
        const { app, profiles, token } = await setup(5);
        const [third, fourth] = profiles.slice(2, 4) as [
          UserProfile,
          UserProfile,
        ];

        const actual = await request(app)
          .get('/api/v1/user-profiles')
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .query({ page: 2, pageSize: 2 })
          .expect(200);
        const expected = [
          {
            id: third.id,
            email: third.email,
            name: third.name,
            createdAt: third.createdAt.toISOString(),
            updatedAt: third.updatedAt.toISOString(),
            hashedPassword: third.hashedPassword,
          },
          {
            id: fourth.id,
            email: fourth.email,
            name: fourth.name,
            createdAt: fourth.createdAt.toISOString(),
            updatedAt: fourth.updatedAt.toISOString(),
            hashedPassword: fourth.hashedPassword,
          },
        ];

        expect(actual.body).toHaveLength(2);
        expect(actual.body).toEqual(expected);
      });

      test('дано: параметры поисковой строки отсутствуют, ожидается: возврат статуса 200 с дефолтными значениями пагинации', async () => {
        const { app, profiles, token } = await setup(15);
        const firstTenProfiles = profiles.slice(0, 10);

        const actual = await request(app)
          .get('/api/v1/user-profiles')
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .expect(200);
        const expected = firstTenProfiles.map(profile => ({
          id: profile.id,
          email: profile.email,
          name: profile.name,
          createdAt: profile.createdAt.toISOString(),
          updatedAt: profile.updatedAt.toISOString(),
          hashedPassword: profile.hashedPassword,
        }));

        expect(actual.body).toHaveLength(10);
        expect(actual.body).toEqual(expected);
      });
    });
  });
});

Мы снова определяем вспомогательную функцию setup. В ней создается приложение, несколько профилей в БД, настраивается очистка для удаления профилей после завершения тестов и генерируется токен JWT для аутентифицированного пользователя.

Далее определяется тест для конечной точки /api/v1/user-profiles:

  • тестирование неаутентифицированного запроса: в этом случае GET-запрос должен возвращать статус 401

  • тестирование пагинации с несколькими профилями: проверяется, что при наличии нескольких профилей и валидной аутентификации, GET-запрос с определенными параметрами поисковой строки (page и pageSize) возвращает корректно пагинированные профили пользователей

  • тестирование пагинации с параметрами поисковой строки: проверяется, что запрос определенной страницы с помощью параметров поисковой строки возвращает правильный набор профилей пользователей

  • тестирование дефолтной пагинации: проверяется, что при отсутствии параметров поисковой строки, API возвращает дефолтное количество профилей (10 в нашем случае)

В тестах аутентификация выполняется путем добавления токена JWT в запросы.

// src/features/user-profile/user-profile-controller.ts
import type { Request, Response } from 'express';
import { z } from 'zod';

import { requireAuthentication } from '~/middleware/require-authentication.js';
import { validateQuery } from '~/middleware/validate.js';

import { retrieveManyUserProfilesFromDatabase } from './user-profile-model.js';

export async function getAllUserProfiles(request: Request, response: Response) {
  requireAuthentication(request, response);
  const query = await validateQuery(
    z.object({
      page: z.coerce.number().positive().default(1),
      pageSize: z.coerce.number().positive().default(10),
    }),
    request,
    response,
  );

  const profiles = await retrieveManyUserProfilesFromDatabase({
    page: query.page,
    pageSize: query.pageSize,
  });

  response.status(200).json(profiles);
}

Благодаря посреднику и фасадам реализация роута является тривиальной. Сначала проверяется, что пользователь аутентифицирован, затем валидируются параметры поисковой строки и, наконец, профили извлекаются из БД.

Добавляем обработчик:

// src/routes.ts
import { Router } from 'express';

import { healthCheckRoutes } from '~/features/health-check/health-check-routes.js';
import { userAuthenticationRoutes } from '~/features/user-authentication/user-authentication-routes.js';
import { userProfileRoutes } from '~/features/user-profile/user-profile-routes.js';

export const apiV1Router = Router();

apiV1Router.use('/health-check', healthCheckRoutes);
apiV1Router.use(userAuthenticationRoutes);
apiV1Router.use('/user-profiles', userProfileRoutes);

Теперь тесты должны проходить.

Напишем тесты для получения профиля пользователя, его обновления и удаления по id:

// src/features/user-profile/user-profile.test.ts
import { createId } from '@paralleldrive/cuid2';
import type { UserProfile } from '@prisma/client';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';

import { buildApp } from '~/app.js';

import {
  generateJwtToken,
  JWT_COOKIE_NAME,
} from '../user-authentication/user-authentication-helpers.js';
import { createPopulatedUserProfile } from './user-profile-factories.js';
import {
  deleteUserProfileFromDatabaseById,
  saveUserProfileToDatabase,
} from './user-profile-model.js';

// Функция setup...

describe('/api/v1/user-profiles', () => {
  describe('/', () => {
    // Тесты получения списка профилей...
  });

  describe('/:id', () => {
    describe('GET', () => {
      test('дано: неаутентифицированный запрос, ожидается: возврат статуса 401', async () => {
        const { app, profiles } = await setup();
        const [profile] = profiles as [UserProfile];

        const { status: actual } = await request(app).get(
          `/api/v1/user-profiles/${profile.id}`,
        );
        const expected = 401;

        expect(actual).toEqual(expected);
      });

      test('дано: профиль существует, ожидается: возврат статуса 200 с профилем', async () => {
        const { app, profiles, token } = await setup();
        const [profile] = profiles as [UserProfile];

        const actual = await request(app)
          .get(`/api/v1/user-profiles/${profile.id}`)
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .expect(200);
        const expected = {
          id: profile.id,
          email: profile.email,
          name: profile.name,
          createdAt: profile.createdAt.toISOString(),
          updatedAt: profile.updatedAt.toISOString(),
          hashedPassword: profile.hashedPassword,
        };

        expect(actual.body).toEqual(expected);
      });

      test('дано: профиля не существует, ожидается: возврат статуса 404 с сообщением об ошибке', async () => {
        const { app, token } = await setup();
        const nonExistentId = createId();
        const actual = await request(app)
          .get(`/api/v1/user-profiles/${nonExistentId}`)
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .expect(404);
        const expected = { message: 'Not Found' };

        expect(actual.body).toEqual(expected);
      });
    });

    describe('PATCH', () => {
      test('дано: неаутентифицированный запрос, ожидается: возврат статуса 401', async () => {
        const { app, profiles } = await setup();
        const [profile] = profiles as [UserProfile];
        const updates = { name: 'Updated Name' };

        const { status: actual } = await request(app)
          .patch(`/api/v1/user-profiles/${profile.id}`)
          .send(updates);
        const expected = 401;

        expect(actual).toEqual(expected);
      });

      test('дано: профиль существует и новые данные валидны, ожидается: возврат статуса 200 с обновленным профилем', async () => {
        const { app, profiles, token } = await setup();
        const [profile] = profiles as [UserProfile];

        const updates = { name: 'Updated Name', ignoredField: 'ignoreMe' };
        const actual = await request(app)
          .patch(`/api/v1/user-profiles/${profile.id}`)
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .send(updates)
          .expect(200);
        const expected = {
          id: profile.id,
          email: profile.email,
          name: updates.name,
          createdAt: profile.createdAt.toISOString(),
          updatedAt: actual.body.updatedAt,
          hashedPassword: profile.hashedPassword,
        };

        expect(actual.body).toEqual(expected);
      });

      test('дано: невалидный id, ожидается: возврат статуса 404 с сообщением об ошибке', async () => {
        const { app, token } = await setup();
        const updates = { name: 'Updated Name' };
        const nonExistentId = createId();

        const actual = await request(app)
          .patch(`/api/v1/user-profiles/${nonExistentId}`)
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .send(updates)
          .expect(404);
        const expected = { message: 'Not Found' };

        expect(actual.body).toEqual(expected);
      });

      test('дано: пустой объект обновления, ожидается: возврат статуса 400 с сообщением об ошибке', async () => {
        const { app, profiles, token } = await setup();
        const [profile] = profiles as [UserProfile];

        const actual = await request(app)
          .patch(`/api/v1/user-profiles/${profile.id}`)
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .send({})
          .expect(400);
        const expected = { message: 'No valid fields to update' };

        expect(actual.body).toEqual(expected);
      });

      test('дано: попытка обновления id, ожидается: возврат статуса 400 с сообщением об ошибке', async () => {
        const { app, profiles, token } = await setup();
        const [profile] = profiles as [UserProfile];

        const updates = { id: 'new-id' };
        const actual = await request(app)
          .patch(`/api/v1/user-profiles/${profile.id}`)
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .send(updates)
          .expect(400);
        const expected = {
          message: 'Bad Request',
          errors: [
            {
              code: 'invalid_type',
              expected: 'never',
              message: 'Invalid input: expected never, received string',
              path: ['id'],
            },
          ],
        };

        expect(actual.body).toEqual(expected);
      });

      test('дано: отсутствует id в URL, ожидается: возврат статуса 404', async () => {
        const { app, token } = await setup();
        const updates = { name: 'Updated Name' };

        const actual = await request(app)
          .patch('/api/v1/user-profiles/')
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .send(updates);
        const expected = 404;

        expect(actual.status).toEqual(expected);
      });
    });

    describe('DELETE', () => {
      test('дано: неаутентифицированный запрос, ожидается: возврат статуса 401', async () => {
        const { app, profiles } = await setup();
        const [profile] = profiles as [UserProfile];

        const { status: actual } = await request(app).delete(
          `/api/v1/user-profiles/${profile.id}`,
        );
        const expected = 401;

        expect(actual).toEqual(expected);
      });

      test('дано: профиль существует, ожидается: возврат статуса 200 с удаленным профилем', async () => {
        const { app, profiles, token } = await setup();
        const [profile] = profiles as [UserProfile];

        const actual = await request(app)
          .delete(`/api/v1/user-profiles/${profile.id}`)
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .expect(200);
        const expected = {
          id: profile.id,
          email: profile.email,
          name: profile.name,
          createdAt: profile.createdAt.toISOString(),
          updatedAt: profile.updatedAt.toISOString(),
          hashedPassword: profile.hashedPassword,
        };

        expect(actual.body).toEqual(expected);
      });

      test('дано: профиля не существует, ожидается: возврат статуса 404 с сообщением об ошибке', async () => {
        const { app, token } = await setup();
        const nonExistentId = createId();

        const actual = await request(app)
          .delete(`/api/v1/user-profiles/${nonExistentId}`)
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
          .expect(404);
        const expected = { message: 'Not Found' };

        expect(actual.body).toEqual(expected);
      });

      test('дано: отсутствует id в URL, ожидается: возврат статуса 404', async () => {
        const { app, token } = await setup();

        const actual = await request(app)
          .delete('/api/v1/user-profiles/')
          .set('Cookie', [`${JWT_COOKIE_NAME}=${token}`]);
        const expected = 404;

        expect(actual.status).toEqual(expected);
      });
    });
  });
});

Для GET-запросов тестируется извлечение профиля по его ID. Если профиль существует, API должен возвращать статус 200 с верными данными профиля; если профиля не существует, ожидается возврат статуса 404 с соответствующим сообщением об ошибке.

Для PATCH-запросов проверяется несколько сценариев обновления. В ответ на попытку неаутентифицированного обновления возвращается статус 401, а в ответ на валидные новые данные существующего профиля - статус 200 с обновленным профилем. Кроме того, тестируются крайние случаи, такие как попытка обновления несуществующего профиля, отправка пустого объекта обновления и попытка модификации иммутабельных полей, таких как ID профиля. Во всех этих случаях должны возвращаться соответствующие ответы с ошибками.

Для DELETE-запросов проверяется, что неаутентифицированное удаление отклоняется со статусом 401. При удалении существующего профиля с валидной аутентификацией ожидается возврат статуса 200 с удаленным профилем. Также тестируется, что попытка удаления несуществующего профиля или отсутствие ID профиля в URL заканчиваются статусом 404 с сообщением об ошибке.

Существует много способов реализовать эти роуты. Мы оставим проверку дубликатов Prisma, а для определения правильной ошибки может быть использована простая вспомогательная функция getErrorMessage. Напишем для нее тесты:

import { faker } from '@faker-js/faker';
import { describe, expect, test } from 'vitest';

import { getErrorMessage } from './get-error-message.js';

describe('getErrorMessage()', () => {
  test('дано: ошибка, ожидается: возврат сообщения об ошибке', () => {
    const message = faker.word.words();

    expect(getErrorMessage(new Error(message))).toEqual(message);
  });

  test('дано: выброс строки, ожидается: возврат строки', () => {
    expect.assertions(1);

    const someString = faker.lorem.words();

    try {
      throw someString;
    } catch (error) {
      expect(getErrorMessage(error)).toEqual(someString);
    }
  });

  test('дано: выброс числа, ожидается: возврат числа в виде строки', () => {
    expect.assertions(1);

    const someNumber = 1;

    try {
      throw someNumber;
    } catch (error) {
      expect(getErrorMessage(error)).toEqual(String(someNumber));
    }
  });

  test('дано: ошибка, расширяющая класс ошибок, ожидается: возврат сообщения об ошибке', () => {
    class CustomError extends Error {
      public constructor(message: string) {
        super(message);
      }
    }

    const message = faker.word.words();

    expect(getErrorMessage(new CustomError(message))).toEqual(message);
  });

  test('дано: кастомный объект со свойством message, ожидается: возврат свойства message объекта', () => {
    const message = faker.word.words();

    expect(getErrorMessage({ message })).toEqual(message);
  });

  test('дано: циклическая зависимость, ожидается: правильная обработка такой зависимости', () => {
    expect.assertions(1);

    const object = { circular: this };

    try {
      throw object;
    } catch (error) {
      expect(getErrorMessage(error)).toEqual('[object Object]');
    }
  });
});

Эти тесты проверяют разные типы ошибок, передаваемые getErrorMessage(). Проверяется, что getErrorMessage() корректно извлекает сообщение из стандартной Error, возвращает строку, если выбрасывается строка или число. Также проверяется, что функция правильно обрабатывает кастомные ошибки и объекты со свойством message, а также справляется с циклическими зависимостями путем возврата дефолтного строкового представления объекта.

Реализуем функцию getErrorMessage:

// src/utils/get-error-message.ts
type ErrorWithMessage = {
  message: string;
};

// Проверяет наличие свойства `message` в стандартных ошибках,
// кастомных ошибках и объектах
function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
  return (
    typeof error === 'object' &&
    error !== null &&
    'message' in error &&
    typeof (error as Record<string, unknown>).message === 'string'
  );
}

function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
  if (isErrorWithMessage(maybeError)) return maybeError;

  try {
    if (typeof maybeError === 'string') return new Error(maybeError);

    return new Error(JSON.stringify(maybeError));
  } catch {
    // JSON.stringify() выбрасывает исключение в случае циклической зависимости.
    // Мы перехватываем его здесь и приводим к строке [object Object]
    return new Error(String(maybeError));
  }
}

/**
 * Извлекает сообщение из ошибки или другого исключения.
 *
 * @param error Ошибка или другое исключение.
 * @returns Строка с сообщением об ошибке.
 *
 * @example
 *
 * Экземпляр Error:
 *
 * ```typescript
 * getErrorMessage(new Error('Something went wrong'))
 * // 'Something went wrong'
 * ```
 *
 * Объект:
 *
 * ```typescript
 * getErrorMessage({ message: 'Something went wrong' })
 * // 'Something went wrong'
 * ```
 *
 * Примитив:
 *
 * ```typescript
 * getErrorMessage('Something went wrong')
 * // 'Something went wrong'
 * ```
 */
export function getErrorMessage(error: unknown) {
  return toErrorWithMessage(error).message;
}

Начинаем с определения типа ErrorWithMessage - объекта со свойством message типа string, затем реализует защитник типа (type guard) isErrorWithMessage для подтверждения этого.

Далее, определяем функцию toErrorWithMessage, которая конвертирует любое выброшенное значение в объект ErrorWithMessage.

Наконец, getErrorMessage() извлекает свойство message из конвертированного объекта, обеспечивая согласованность сообщений об ошибках.

Теперь можно реализовать роуты:

// src/features/user-profile/user-profile-controller.ts
// Другие импорты...
import {
  validateBody,
  validateParams,
  validateQuery,
} from '~/middleware/validate.js';
import { getErrorMessage } from '~/utils/get-error-message.js';

import {
  deleteUserProfileFromDatabaseById,
  retrieveManyUserProfilesFromDatabase,
  retrieveUserProfileFromDatabaseById,
  updateUserProfileInDatabaseById,
} from './user-profile-model.js';

// Обработчик получения списка профилей...

export async function getUserProfileById(request: Request, response: Response) {
  requireAuthentication(request, response);
  const { id } = await validateParams(
    z.object({ id: z.cuid2() }),
    request,
    response,
  );
  const profile = await retrieveUserProfileFromDatabaseById(id);

  if (profile) {
    response.status(200).json(profile);
  } else {
    response.status(404).json({ message: 'Not Found' });
  }
}

export async function updateUserProfile(request: Request, response: Response) {
  requireAuthentication(request, response);
  const { id } = await validateParams(
    z.object({ id: z.cuid2() }),
    request,
    response,
  );

  const body = await validateBody(
    z.object({
      email: z.email().optional(),
      name: z.string().optional(),
      id: z.never().optional(),
    }),
    request,
    response,
  );

  // Определяем наличие полей для обновления
  if (Object.keys(body).length === 0) {
    response.status(400).json({ message: 'No valid fields to update' });
    return;
  }

  // Определяем попытку обновления id
  if ('id' in body) {
    response.status(400).json({ message: 'ID cannot be updated' });
    return;
  }

  try {
    const updatedProfile = await updateUserProfileInDatabaseById({
      id,
      data: body,
    });
    response.status(200).json(updatedProfile);
  } catch (error) {
    const message = getErrorMessage(error);

    if (message.includes('No record was found for an update')) {
      response.status(404).json({ message: 'Not Found' });
    } else if (message.includes('Unique constraint failed')) {
      response.status(409).json({ message: 'Profile already exists' });
    } else {
      throw error;
    }
  }
}

export async function deleteUserProfile(request: Request, response: Response) {
  requireAuthentication(request, response);
  const { id } = await validateParams(
    z.object({ id: z.cuid2() }),
    request,
    response,
  );

  try {
    const deletedProfile = await deleteUserProfileFromDatabaseById(id);
    response.status(200).json(deletedProfile);
  } catch (error) {
    const message = getErrorMessage(error);

    if (message.includes('No record was found for a delete')) {
      response.status(404).json({ message: 'Not Found' });
    } else {
      throw error;
    }
  }
}

Добавляем эти роуты в роутер:

// src/features/user-profile/user-profile-routes.ts
import { Router } from 'express';

import { asyncHandler } from '~/utils/async-handler.js';

import {
  deleteUserProfile,
  getAllUserProfiles,
  getUserProfileById,
  updateUserProfile,
} from './user-profile-controller.js';

const router = Router();

router.get('/', asyncHandler(getAllUserProfiles));
router.get('/:id', asyncHandler(getUserProfileById));
router.patch('/:id', asyncHandler(updateUserProfile));
router.delete('/:id', asyncHandler(deleteUserProfile));

export { router as userProfileRoutes };

Теперь тесты должны проходить.

Для создания профиля пользователя не нужен отдельный роут, поскольку за это отвечает роут регистрации. Разумеется, в некоторых приложениях у пользователей будет возможность создавать аккаунты для других пользователей. В этом случае потребуется отдельный роут.

В этом туториале мы охватили 20% навыков по работе с Express с поддержкой TypeScript, что охватывает 80% (прим. пер.: я бы сказал процентов 40:)) функционала реальных приложений. Теперь идите и создайте что-нибудь классное!


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале