Создание Node.js/TypeScript REST API, часть 3: MongoDB, аутентификация и автоматические тесты

Опубликовано: 2022-03-11

На этом этапе нашей серии статей о том, как создать Node.js REST API с помощью Express.js и TypeScript, мы создали работающую серверную часть и разделили наш код на конфигурацию маршрутов, службы, промежуточное ПО, контроллеры и модели. Если вы готовы следовать дальше, клонируйте пример репозитория и запустите git checkout toptal-article-02 .

REST API с Mongoose, аутентификацией и автоматическим тестированием

В этой третьей и последней статье мы продолжим разработку нашего REST API, добавив:

  • Mongoose , чтобы мы могли работать с MongoDB и заменить наш DAO в памяти реальной базой данных.
  • Возможности аутентификации и разрешений, чтобы потребители API могли использовать веб-токен JSON (JWT) для безопасного доступа к нашим конечным точкам.
  • Автоматизированное тестирование с использованием Mocha (среда тестирования), Chai (библиотека утверждений) и SuperTest (модуль абстракции HTTP) для проверки регрессий по мере роста и изменения базы кода.

Попутно мы добавим библиотеки проверки и безопасности, получим некоторый опыт работы с Docker и предложим читателям несколько дополнительных тем, библиотек и навыков, которые было бы полезно изучить при создании и расширении собственных API REST.

Установка MongoDB в качестве контейнера

Начнем с замены нашей базы данных в памяти из предыдущей статьи на реальную.

Чтобы создать локальную базу данных для разработки, мы можем установить MongoDB локально. Но различия между средами (например, дистрибутивы и версии ОС) могут создавать проблемы. Чтобы избежать этого, мы воспользуемся этой возможностью, чтобы использовать стандартный отраслевой инструмент: контейнер Docker.

Единственное, что нужно сделать читателям, это установить Docker, а затем установить Docker Compose. После установки запуск docker -v в терминале должен дать номер версии Docker.

Теперь, чтобы запустить MongoDB, в корне нашего проекта мы создадим файл YAML с именем docker-compose.yml , содержащий следующее:

 version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017"

Docker Compose позволяет нам запускать несколько контейнеров одновременно с одним файлом конфигурации. В конце этой статьи мы также рассмотрим запуск нашего серверного интерфейса REST API в Docker, но сейчас мы просто будем использовать его для запуска MongoDB без необходимости устанавливать его локально:

 sudo docker-compose up -d

Команда up запустит определенный контейнер, прослушивая стандартный порт MongoDB 27017. Переключатель -d отключит команду от терминала. Если все работает без проблем, мы должны увидеть такое сообщение:

 Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done

Это также создаст новый каталог data в корне проекта, поэтому мы должны добавить строку data в .gitignore .

Теперь, если нам нужно закрыть наш контейнер MongoDB Docker, нам просто нужно запустить sudo docker-compose down и мы должны увидеть следующий вывод:

 Stopping toptal-rest-series_mongo_1 ... done Removing toptal-rest-series_mongo_1 ... done Removing network toptal-rest-series_default

Это все, что нам нужно знать, чтобы запустить серверную часть Node.js/MongoDB REST API. Давайте удостоверимся, что мы использовали sudo docker-compose up -d , чтобы MongoDB была готова к использованию нашим приложением.

Использование Mongoose для доступа к MongoDB

Для связи с MongoDB наша серверная часть будет использовать библиотеку моделирования объектных данных (ODM) под названием Mongoose. Хотя Mongoose довольно прост в использовании, стоит ознакомиться с документацией, чтобы узнать обо всех расширенных возможностях, которые он предлагает для реальных проектов.

Для установки Mongoose мы используем следующее:

 npm i mongoose

Давайте настроим службу Mongoose для управления подключением к нашему экземпляру MongoDB. Поскольку этот сервис может использоваться несколькими ресурсами, мы добавим его в common папку нашего проекта.

Конфигурация проста. Хотя это не является строго обязательным, у нас будет объект mongooseOptions для настройки следующих параметров подключения Mongoose:

  • useNewUrlParser : если для этого параметра не установлено значение true , Mongoose печатает предупреждение об устаревании.
  • useUnifiedTopology : документация Mongoose рекомендует установить для этого параметра значение true , чтобы использовать более новый механизм управления соединениями.
  • serverSelectionTimeoutMS : для UX этого демонстрационного проекта более короткое время, чем 30 секунд по умолчанию, означает, что любой читатель, который забудет запустить MongoDB до Node.js, быстрее увидит полезный отзыв об этом, а не явно не отвечающий сервер. .
  • useFindAndModify : установка значения false также позволяет избежать предупреждения об устаревании, но это упоминается в разделе документации об устаревании, а не среди параметров подключения Mongoose. В частности, это заставляет Mongoose использовать более новую встроенную функцию MongoDB вместо старой оболочки Mongoose.

Комбинируя эти параметры с некоторой логикой инициализации и повторных попыток, вот окончательный файл common/services/mongoose.service.ts :

 import mongoose from 'mongoose'; import debug from 'debug'; const log: debug.IDebugger = debug('app:mongoose-service'); class MongooseService { private count = 0; private mongooseOptions = { useNewUrlParser: true, useUnifiedTopology: true, serverSelectionTimeoutMS: 5000, useFindAndModify: false, }; constructor() { this.connectWithRetry(); } getMongoose() { return mongoose; } connectWithRetry = () => { log('Attempting MongoDB connection (will retry if needed)'); mongoose .connect('mongodb://localhost:27017/api-db', this.mongooseOptions) .then(() => { log('MongoDB is connected'); }) .catch((err) => { const retrySeconds = 5; log( `MongoDB connection unsuccessful (will retry #${++this .count} after ${retrySeconds} seconds):`, err ); setTimeout(this.connectWithRetry, retrySeconds * 1000); }); }; } export default new MongooseService();

Обязательно уясните разницу между функцией connect() из Mongoose и нашей собственной сервисной функцией connectWithRetry() :

  • mongoose.connect() пытается подключиться к нашей локальной службе MongoDB (работает с docker-compose ) и истечет время ожидания через serverSelectionTimeoutMS миллисекунд.
  • MongooseService.connectWithRetry() повторяет вышеуказанное, если наше приложение запускается, но служба MongoDB еще не запущена. Поскольку он находится в одноэлементном конструкторе, connectWithRetry() будет запущен только один раз, но он будет повторять вызов connect() бесконечно, с паузой в retrySeconds секунд всякий раз, когда происходит тайм-аут.

Наш следующий шаг — заменить нашу предыдущую базу данных в памяти на MongoDB!

Удаление нашей базы данных в памяти и добавление MongoDB

Раньше мы использовали базу данных в памяти, чтобы сосредоточиться на других модулях, которые мы создавали. Чтобы вместо этого использовать Mongoose, нам придется полностью реорганизовать users.dao.ts . Для начала нам понадобится еще один оператор import :

 import mongooseService from '../../common/services/mongoose.service';

Теперь давайте удалим из определения класса UsersDao все, кроме конструктора. Мы можем начать заполнять его, создав пользовательскую Schema для Mongoose перед конструктором:

 Schema = mongooseService.getMongoose().Schema; userSchema = new this.Schema({ _id: String, email: String, password: { type: String, select: false }, firstName: String, lastName: String, permissionFlags: Number, }, { id: false }); User = mongooseService.getMongoose().model('Users', this.userSchema);

Это определяет нашу коллекцию MongoDB и добавляет специальную функцию, которой не было в нашей базе данных в памяти: select: false в поле password будет скрывать это поле всякий раз, когда мы получаем пользователя или перечисляем всех пользователей.

Наша пользовательская схема, вероятно, выглядит знакомой, потому что она похожа на наши сущности DTO. Основное отличие состоит в том, что мы определяем, какие поля должны существовать в нашей коллекции MongoDB под названием Users , а объекты DTO определяют, какие поля принимать в HTTP-запросе.

Эта часть нашего подхода не меняется, поэтому мы по-прежнему импортируем наши три DTO в верхней части users.dao.ts . Но перед реализацией наших операций метода CRUD мы обновим наши DTO двумя способами.

Изменение DTO № 1: id против _id

Поскольку Mongoose автоматически делает поле _id доступным, мы удалим поле id из DTO. В любом случае он будет исходить из параметров запроса маршрута.

Помните, что модели Mongoose по умолчанию предоставляют получатель виртуального id , поэтому мы отключили эту опцию выше с помощью { id: false } , чтобы избежать путаницы. Но это нарушило нашу ссылку на user.id в промежуточном программном обеспечении пользователя validateSameEmailBelongToSameUser() — вместо этого нам нужен user._id .

Некоторые базы данных используют условное обозначение id , а другие — _id , поэтому идеального интерфейса не существует. В нашем примере проекта с использованием Mongoose мы просто обратили внимание на то, какой из них мы используем в какой точке кода, но несоответствие все равно будет отображаться для потребителей API:

Пути пяти типов запросов: 1. Непараметризованный GET-запрос к /users проходит через контроллер listUsers() и возвращает массив объектов, каждый из которых имеет ключ _id. 2. Непараметризованный POST-запрос к /users проходит через контроллер createUser(), который использует вновь сгенерированное значение идентификатора и возвращает его в объекте с ключом идентификатора. 3. Непараметризованный запрос к /auth проходит через промежуточное ПО verifyUserPassword(), которое выполняет поиск в MongoDB для установки req.body.userId; оттуда запрос проходит через контроллер createJWT(), который использует req.body.userId и возвращает объект с ключами accessToken и refreshToken. 4. Непараметризованный запрос к /auth/refresh-token проходит через промежуточное ПО validJWTNeeded(), которое устанавливает res.locals.jwt.userId, и промежуточное ПО validRefreshNeeded(), которое использует res.locals.jwt.userId, а также выполняет Поиск MongoDB для установки req.body.userId; оттуда путь проходит через тот же контроллер и ответ, что и в предыдущем случае. 5. Параметризованный запрос к /users проходит через конфигурацию UsersRoutes, которая заполняет req.params.userId через Express.js, затем промежуточное ПО validJWTNeeded(), которое устанавливает res.locals.jwt.userId, затем другие функции промежуточного ПО (которые используют req. params.userId, res.locals.jwt.userId или оба; и/или выполнить поиск в MongoDB и использовать результат._id), и, наконец, через функцию UsersController, которая будет использовать req.body.id и возвращать либо не тело, либо объект с ключом _id.
Использование и раскрытие идентификаторов пользователей в окончательном проекте REST API. Обратите внимание, что различные внутренние соглашения подразумевают разные источники данных идентификатора пользователя: параметр прямого запроса, данные, закодированные с помощью JWT, или только что извлеченная запись из базы данных.

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

Изменение DTO № 2: подготовка к разрешениям на основе флагов

Мы также переименуем permissionLevel в permissionFlags в DTO, чтобы отразить более сложную систему разрешений, которую мы будем реализовывать, а также приведенное выше определение userSchema Mongoose.

DTO: как насчет принципа DRY?

Помните, DTO содержит только те поля, которые мы хотим передать между клиентом API и нашей базой данных. Это может показаться неудачным, потому что между моделью и DTO есть некоторое совпадение, но остерегайтесь слишком сильно настаивать на DRY за счет «безопасности по умолчанию». Если для добавления поля требуется добавить его только в одном месте, разработчики могут непреднамеренно раскрыть его в API, когда оно предназначалось только для внутреннего использования. Это потому, что процесс не заставляет их думать о хранении и передаче данных как о двух отдельных контекстах с двумя потенциально разными наборами требований.

После внесения изменений в DTO мы можем реализовать операции с методами CRUD (после конструктора UsersDao ), начиная с create :

 async addUser(userFields: CreateUserDto) { const userId = shortid.generate(); const user = new this.User({ _id: userId, ...userFields, permissionFlags: 1, }); await user.save(); return userId; }

Обратите внимание, что независимо от того, что потребитель API отправляет для permissionFlags через userFields , мы затем переопределяем его со значением 1 .

Затем мы прочитали основные функции для получения пользователя по идентификатору, получения пользователя по электронной почте и списка пользователей с нумерацией страниц:

 async getUserByEmail(email: string) { return this.User.findOne({ email: email }).exec(); } async getUserById(userId: string) { return this.User.findOne({ _id: userId }).populate('User').exec(); } async getUsers(limit = 25, page = 0) { return this.User.find() .limit(limit) .skip(limit * page) .exec(); }

Для обновления пользователя будет достаточно одной функции DAO, потому что базовая функция Mongoose findOneAndUpdate() может обновить весь документ или только его часть. Обратите внимание, что наша собственная функция будет принимать userFields как PatchUserDto или PutUserDto , используя тип объединения TypeScript (обозначается символом | ):

 async updateUserById( userId: string, userFields: PatchUserDto | PutUserDto ) { const existingUser = await this.User.findOneAndUpdate( { _id: userId }, { $set: userFields }, { new: true } ).exec(); return existingUser; }

new: true указывает Mongoose вернуть объект таким, какой он есть после обновления, а не таким, каким он был изначально.

Удалить лаконично с Mongoose:

 async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); }

Читатели могут заметить, что каждый из вызовов функций-членов User привязан к вызову exec() . Это необязательно, но разработчики Mongoose рекомендуют его, поскольку он обеспечивает лучшую трассировку стека при отладке.

После кодирования нашего DAO нам нужно немного обновить users.service.ts из нашей предыдущей статьи, чтобы он соответствовал новым функциям. Нет необходимости в серьезном рефакторинге, достаточно трех штрихов:

 @@ -16,3 +16,3 @@ class UsersService implements CRUD { async list(limit: number, page: number) { - return UsersDao.getUsers(); + return UsersDao.getUsers(limit, page); } @@ -20,3 +20,3 @@ class UsersService implements CRUD { async patchById(id: string, resource: PatchUserDto): Promise<any> { - return UsersDao.patchUserById(id, resource); + return UsersDao.updateUserById(id, resource); } @@ -24,3 +24,3 @@ class UsersService implements CRUD { async putById(id: string, resource: PutUserDto): Promise<any> { - return UsersDao.putUserById(id, resource); + return UsersDao.updateUserById(id, resource); }

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

  • Мы используем updateUserById() как для PUT , так и для PATCH , как мы намекали выше. (Как упоминалось в части 2, мы следуем типичным реализациям REST API, а не пытаемся буквально придерживаться конкретных RFC. Среди прочего, это означает, что запросы PUT не создают новые объекты, если они не существуют; таким образом, наша серверная часть не передает управление генерацией идентификаторов потребителям API.)
  • Мы передаем параметры limit и page в getUsers() , так как наша новая реализация DAO будет использовать их.

Основная структура здесь представляет собой довольно надежный шаблон. Например, его можно использовать повторно, если разработчики хотят заменить Mongoose и MongoDB на что-то вроде TypeORM и PostgreSQL. Как и выше, такая замена просто потребует рефакторинга отдельных функций DAO при сохранении их сигнатур, чтобы они соответствовали остальной части кода.

Тестирование нашего REST API с поддержкой Mongoose

Давайте запустим серверную часть API с помощью npm start . Затем мы попробуем создать пользователя:

 curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'

Объект ответа содержит новый идентификатор пользователя:

 { "id": "7WYQoVZ3E" }

Как и в предыдущей статье, остальные ручные тесты будет проще использовать с переменными окружения:

 REST_API_EXAMPLE_

Обновление пользователя выглядит так:

 curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }'

Ответ должен начинаться с HTTP/1.1 204 No Content . (Без переключателя --include ответ не будет напечатан, что соответствует нашей реализации.)

Если теперь мы заставим пользователя проверить указанные выше обновления… :

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }'

… ответ показывает ожидаемые поля, включая поле _id , о котором говорилось выше:

 { "_id": "7WYQoVZ3E", "email": "[email protected]", "permissionFlags": 1, "__v": 0, "firstName": "Marcos", "lastName": "Silva" }

Также есть специальное поле __v , используемое Mongoose для управления версиями; он будет увеличиваться каждый раз при обновлении этой записи.

Далее перечислим пользователей:

 curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

Ожидаемый ответ такой же, просто завернутый в [] .

Теперь, когда наш пароль надежно сохранен, давайте удостоверимся, что мы можем удалить пользователя:

 curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'

Мы снова ожидаем ответа 204.

Читатели могут задаться вопросом, правильно ли работает поле пароля, поскольку наш select: false в определении Schema Mongoose скрыл его из нашего вывода GET , как и предполагалось. Давайте повторим наш первоначальный POST , чтобы снова создать пользователя, а затем проверим. (Не забудьте сохранить новый идентификатор на потом.)

Скрытые пароли и прямая отладка данных с помощью контейнеров MongoDB

Чтобы убедиться, что пароли хранятся безопасно (т. е. в хешированном виде, а не в виде обычного текста), разработчики могут напрямую проверять данные MongoDB. Один из способов — получить доступ к стандартному клиенту CLI mongo из работающего контейнера Docker:

 sudo docker exec -it toptal-rest-series_mongo_1 mongo

Оттуда выполнение use api-db с последующим db.users.find().pretty() выведет список всех пользовательских данных, включая пароли.

Те, кто предпочитает графический интерфейс, могут установить отдельный клиент MongoDB, например Robo 3T:

Левая боковая панель показывает подключения к базе данных, каждое из которых содержит иерархию таких вещей, как базы данных, функции и пользователи. На главной панели есть вкладки для выполнения запросов. Текущая вкладка подключена к базе данных API-DB на локальном хосте: 27017 с запросом «db.getCollection('users').find({})» с одним результатом. Результат имеет четыре поля: _id, пароль, адрес электронной почты и __v. Поле пароля начинается с «$argon2$i$v=19$m=4096,t=3,p=1$» и заканчивается солью и хэшем, разделенными знаком доллара и закодированными по основанию 64.
Изучение данных MongoDB напрямую с помощью Robo 3T.

Префикс пароля ( $argon2... ) является частью формата строки PHC, и он намеренно хранится без изменений: тот факт, что Argon2 и его общие параметры упоминаются, не поможет хакеру определить оригинальные пароли, если им удастся украсть база данных. Сохраненный пароль можно дополнительно усилить с помощью соления — метода, который мы будем использовать ниже с JWT. Мы оставляем читателю в качестве упражнения применение соли выше и изучение разницы между сохраненными значениями, когда два пользователя вводят один и тот же пароль.

Теперь мы знаем, что Mongoose успешно отправляет данные в нашу базу данных MongoDB. Но откуда мы знаем, что наши потребители API будут отправлять соответствующие данные в своих запросах на наши пользовательские маршруты?

Добавление экспресс-валидатора

Существует несколько способов выполнить проверку поля. В этой статье мы будем использовать экспресс-валидатор, который достаточно стабилен, прост в использовании и прилично документирован. Хотя мы могли бы использовать функцию проверки, которая поставляется с Mongoose, экспресс-валидатор предоставляет дополнительные функции. Например, он поставляется с готовым валидатором для адресов электронной почты, который в Mongoose потребовал бы от нас написания собственного валидатора.

Давайте установим его:

 npm i express-validator

Чтобы установить поля, которые мы хотим проверить, мы будем использовать метод body() , который мы импортируем в наши users.routes.config.ts . Метод body() проверит поля и сгенерирует список ошибок, хранящийся в объекте express.Request , в случае сбоя.

Затем нам понадобится собственное промежуточное ПО для проверки и использования списка ошибок. Поскольку эта логика, вероятно, будет работать одинаково для разных маршрутов, давайте создадим common/middleware/body.validation.middleware.ts со следующим:

 import express from 'express'; import { validationResult } from 'express-validator'; class BodyValidationMiddleware { verifyBodyFieldsErrors( req: express.Request, res: express.Response, next: express.NextFunction ) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).send({ errors: errors.array() }); } next(); } } export default new BodyValidationMiddleware();

Теперь мы готовы обрабатывать любые ошибки, генерируемые функцией body() . Давайте добавим следующее обратно в users.routes.config.ts :

 import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator';

Теперь мы можем обновить наши маршруты следующим образом:

 @@ -15,3 +17,6 @@ export class UsersRoutes extends CommonRoutesConfig { .post( - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailDoesntExist, @@ -28,3 +33,10 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.put(`/users/:userId`, [ - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + body('firstName').isString(), + body('lastName').isString(), + body('permissionFlags').isInt(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailBelongToSameUser, @@ -34,2 +46,11 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.patch(`/users/:userId`, [ + body('email').isEmail().optional(), + body('password') + .isLength({ min: 5 }) + .withMessage('Password must be 5+ characters') + .optional(), + body('firstName').isString().optional(), + body('lastName').isString().optional(), + body('permissionFlags').isInt().optional(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validatePatchEmail,

Обязательно добавляйте BodyValidationMiddleware.verifyBodyFieldsErrors в каждый маршрут после любых присутствующих строк body() , иначе ни одна из них не будет иметь эффекта.

Обратите внимание, как мы обновили наши маршруты POST и PUT , чтобы использовать экспресс-валидатор вместо нашей собственной функции validateRequiredUserBodyFields . Так как эти маршруты были единственными, использующими эту функцию, ее реализацию можно удалить из users.middleware.ts .

Вот и все! Читатели могут перезапустить Node.js и попробовать результат, используя свои любимые клиенты REST, чтобы увидеть, как он обрабатывает различные входные данные. Не забудьте изучить документацию экспресс-валидатора для дополнительных возможностей; наш пример является лишь отправной точкой для проверки запроса.

Достоверные данные — это один из аспектов, который необходимо обеспечить; действительные пользователи и действия - это другое.

Процесс аутентификации и разрешений (или «авторизации»)

Наше приложение Node.js предоставляет полный набор users/ конечных точек, позволяя потребителям API создавать, обновлять и составлять список пользователей. Но каждая конечная точка разрешает неограниченный публичный доступ. Это распространенный шаблон, запрещающий пользователям изменять данные друг друга, а посторонним доступ к любой конечной точке, которую мы не хотим публиковать.

Эти ограничения связаны с двумя основными аспектами, и оба они сокращаются до «авторизации». Аутентификация касается того, от кого исходит запрос, а авторизация касается того, разрешено ли им делать то, что они запрашивают. Важно быть в курсе того, какой из них обсуждается. Даже без коротких форм стандартные коды ответов HTTP могут запутать проблему: 401 Unauthorized — об аутентификации, а 403 Forbidden — об авторизации. Мы ошибемся на стороне «auth», обозначающего «аутентификацию» в именах модулей, и будем использовать «разрешения» для вопросов авторизации.

Даже без коротких форм стандартные коды ответов HTTP могут запутать проблему: 401 Unauthorized — об аутентификации, а 403 Forbidden — об авторизации.

Твитнуть

Существует множество подходов к аутентификации для изучения, в том числе сторонние поставщики удостоверений, такие как Auth0. В этой статье мы выбрали простую, но масштабируемую реализацию. Он основан на JWT.

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

Идея состоит в том, чтобы требовать от клиентов отправки действительного JWT внутри каждого непубличного запроса. Это позволяет нам убедиться, что клиент недавно имел действительные учетные данные для конечной точки, которую он хочет использовать, без необходимости отправлять учетные данные по сети с каждым запросом.

Но как это впишется в наш пример кодовой базы API? Легко: с помощью промежуточного программного обеспечения мы можем использовать его в нашей конфигурации маршрута!

Добавление модуля аутентификации

Давайте сначала настроим, что будет в наших JWT. Здесь мы начнем использовать поле permissionFlags из нашего пользовательского ресурса, но только потому, что это удобные метаданные для шифрования в JWT, а не потому, что JWT по своей сути имеют какое-либо отношение к детализированной логике разрешений.

Перед созданием промежуточного программного обеспечения, генерирующего JWT, нам нужно добавить специальную функцию в users.dao.ts для получения поля пароля, поскольку мы настроили Mongoose, чтобы он обычно избегал его получения:

 async getUserByEmailWithPassword(email: string) { return this.User.findOne({ email: email }) .select('_id email permissionFlags +password') .exec(); }

И в users.service.ts :

 async getUserByEmailWithPassword(email: string) { return UsersDao.getUserByEmailWithPassword(email); }

Теперь давайте создадим папку auth в корне нашего проекта — мы добавим конечную точку, чтобы позволить потребителям API создавать JWT. Во-первых, давайте создадим для него часть промежуточного программного обеспечения по адресу auth/middleware/auth.middleware.ts в виде синглтона с именем AuthMiddleware .

Нам понадобится import s:

 import express from 'express'; import usersService from '../../users/services/users.service'; import * as argon2 from 'argon2';

В классе AuthMiddleware мы создадим промежуточную функцию для проверки того, указал ли пользователь API действительные учетные данные для входа в свой запрос:

 async verifyUserPassword( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( req.body.email ); if (user) { const passwordHash = user.password; if (await argon2.verify(passwordHash, req.body.password)) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } } // Giving the same message in both cases // helps protect against cracking attempts: res.status(400).send({ errors: ['Invalid email and/or password'] }); }

Что касается промежуточного программного обеспечения для обеспечения существования email и password в req.body , мы будем использовать экспресс-валидатор, когда позже настроим маршрут для использования указанной выше verifyUserPassword() .

Хранение секретов JWT

Чтобы сгенерировать JWT, нам понадобится секрет JWT, который мы будем использовать для подписи наших сгенерированных JWT, а также для проверки входящих JWT из клиентских запросов. Вместо того, чтобы жестко кодировать значение секрета JWT в файле TypeScript, мы будем хранить его в отдельном файле «переменной среды», .env , который никогда не следует передавать в репозиторий кода .

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

В реальных проектах особенно необходимо следовать лучшим практикам JWT, различая секреты JWT в зависимости от среды (разработка, подготовка, производство и т. д.).

Наш файл .env (в корне проекта) должен использовать следующий формат, но не должен сохранять то же секретное значение:

 JWT_SECRET=My!@!Se3cr8tH4sh3

Простой способ загрузить эти переменные в наше приложение — использовать библиотеку dotenv:

 npm i dotenv

Единственная необходимая конфигурация — вызвать dotenv.config() , как только мы запустим наше приложение. В самом верху app.ts мы добавим:

 import dotenv from 'dotenv'; const dotenvResult = dotenv.config(); if (dotenvResult.error) { throw dotenvResult.error; }

Контроллер аутентификации

Последним предварительным условием генерации JWT является установка библиотеки jsonwebtoken и ее типов TypeScript:

 npm i jsonwebtoken npm i --save-dev @types/jsonwebtoken

Теперь давайте создадим контроллер /auth в auth/controllers/auth.controller.ts . Нам не нужно импортировать библиотеку dotenv здесь, потому что ее импорт в app.ts делает содержимое файла .env доступным во всем приложении через глобальный объект Node.js с именем process :

 import express from 'express'; import debug from 'debug'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; const log: debug.IDebugger = debug('app:auth-controller'); /** * This value is automatically populated from .env, a file which you will have * to create for yourself at the root of the project. * * See .env.example in the repo for the required format. */ // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; const tokenExpirationInSeconds = 36000; class AuthController { async createJWT(req: express.Request, res: express.Response) { try { const refreshId = req.body.userId + jwtSecret; const salt = crypto.createSecretKey(crypto.randomBytes(16)); const hash = crypto .createHmac('sha512', salt) .update(refreshId) .digest('base64'); req.body.refreshKey = salt.export(); const token = jwt.sign(req.body, jwtSecret, { expiresIn: tokenExpirationInSeconds, }); return res .status(201) .send({ accessToken: token, refreshToken: hash }); } catch (err) { log('createJWT error: %O', err); return res.status(500).send(); } } } export default new AuthController();

Библиотека jsonwebtoken подпишет новый токен с помощью нашего jwtSecret . Мы также создадим соль и хэш, используя встроенный в Node.js модуль crypto , а затем используем их для создания refreshToken , с помощью которого потребители API смогут обновить текущий JWT. иметь возможность масштабироваться.

В чем разница между refreshKey , refreshToken и accessToken ? *Token отправляется нашим потребителям API с идеей, что accessToken используется для любого запроса, помимо того, что доступно для широкой публики, а refreshToken используется для запроса замены просроченного accessToken . С другой стороны, refreshKey используется для передачи переменной salt , зашифрованной в refreshToken , обратно в наше промежуточное программное обеспечение обновления, о котором мы поговорим ниже.

Обратите внимание, что наша реализация имеет срок действия маркера jsonwebtoken для нас. Если срок действия JWT истек, клиенту потребуется пройти аутентификацию еще раз.

Начальный маршрут аутентификации REST API Node.js

Теперь давайте настроим конечную точку в auth/auth.routes.config.ts :

 import { CommonRoutesConfig } from '../common/common.routes.config'; import authController from './controllers/auth.controller'; import authMiddleware from './middleware/auth.middleware'; import express from 'express'; import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator'; export class AuthRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'AuthRoutes'); } configureRoutes(): express.Application { this.app.post(`/auth`, [ body('email').isEmail(), body('password').isString(), BodyValidationMiddleware.verifyBodyFieldsErrors, authMiddleware.verifyUserPassword, authController.createJWT, ]); return this.app; } }

И не забудьте добавить его в наш файл app.ts :

 // ... import { AuthRoutes } from './auth/auth.routes.config'; // ... routes.push(new AuthRoutes(app)); // independent: can go before or after UsersRoute // ...

Мы готовы перезапустить Node.js и протестировать сейчас, убедившись, что мы сопоставляем все учетные данные, которые мы использовали для создания нашего тестового пользователя ранее:

 curl --request POST 'localhost:3000/auth' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'

Ответ будет примерно таким:

 { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8", "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ==" }

Как и раньше, давайте установим некоторые переменные среды для удобства, используя приведенные выше значения:

 REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"

Здорово! У нас есть токен доступа и токен обновления, но нам нужно какое-то промежуточное ПО, которое может сделать с ними что-то полезное.

ПО промежуточного слоя JWT

Нам понадобится новый тип TypeScript для обработки структуры JWT в ее декодированной форме. Создайте common/types/jwt.ts с этим:

 export type Jwt = { refreshKey: string; userId: string; permissionFlags: string; };

Давайте реализуем функции промежуточного программного обеспечения для проверки наличия токена обновления, проверки токена обновления и проверки JWT. Все три можно добавить в новый файл auth/middleware/jwt.middleware.ts :

 import express from 'express'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { Jwt } from '../../common/types/jwt'; import usersService from '../../users/services/users.service'; // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; class JwtMiddleware { verifyRefreshBodyField( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.refreshToken) { return next(); } else { return res .status(400) .send({ errors: ['Missing required field: refreshToken'] }); } } async validRefreshNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( res.locals.jwt.email ); const salt = crypto.createSecretKey( Buffer.from(res.locals.jwt.refreshKey.data) ); const hash = crypto .createHmac('sha512', salt) .update(res.locals.jwt.userId + jwtSecret) .digest('base64'); if (hash === req.body.refreshToken) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } else { return res.status(400).send({ errors: ['Invalid refresh token'] }); } } validJWTNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.headers['authorization']) { try { const authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { res.locals.jwt = jwt.verify( authorization[1], jwtSecret ) as Jwt; next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } } } export default new JwtMiddleware();

The validRefreshNeeded() function also verifies if the refresh token is correct for a specific user ID. If it is, then below we'll reuse authController.createJWT to generate a new JWT for the user.

We also have validJWTNeeded() , which validates whether the API consumer sent a valid JWT in the HTTP headers respecting the convention Authorization: Bearer <token> . (Yes, that's another unfortunate “auth” conflation.)

Now to configure a new route for refreshing the token and the permission flags encoded within it.

JWT Refresh Route

In auth.routes.config.ts we'll import our new middleware:

 import jwtMiddleware from './middleware/jwt.middleware';

Then we'll add the following route:

 this.app.post(`/auth/refresh-token`, [ jwtMiddleware.validJWTNeeded, jwtMiddleware.verifyRefreshBodyField, jwtMiddleware.validRefreshNeeded, authController.createJWT, ]);

Now we can test if it is working properly with the accessToken and refreshToken we received earlier:

 curl --request POST 'localhost:3000/auth/refresh-token' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw "{ \"refreshToken\": \"$REST_API_EXAMPLE_REFRESH\" }"

We should expect to receive a new accessToken and a new refreshToken to be used later. We leave it as an exercise for the reader to ensure that the back end invalidates previous tokens and limits how often new ones can be requested.

Now our API consumers are able to create, validate, and refresh JWTs. Let's look at some permissions concepts, then implement one and integrate it with our JWT middleware in our user routes.

User Permissions

Once we know who an API client is, we want to know whether they're allowed to use the resource they're requesting. It's quite common to manage combinations of permissions for each user. Without adding much complexity, this allows for more flexibility than a traditional “access level” strategy. Regardless of the business logic we use for each permission, it's quite straightforward to create a generic way to handle it.

Bitwise AND ( & ) and Powers of Two

To manage permissions, we'll leverage JavaScript's built-in bitwise AND operator, & . This approach lets us store a whole set of permissions information as a single, per-user number, with each of its binary digits representing whether the user has permission to do something. But there's no need to worry about the math behind it too much—the point is that it's easy to use.

All we need to do is define each kind of permission (a permission flag ) as a power of 2 (1, 2, 4, 8, 16, 32, …). Then we can attach business logic to each flag, up to a maximum of 31 flags. For example, an audio-accessible, international blog might have these permissions:

  • 1: Authors can edit text.
  • 2: Illustrators can replace illustrations.
  • 4: Narrators can replace the audio file corresponding to any paragraph.
  • 8: Translators can edit translations.

This approach allows for all sorts of permission flag combinations for users:

  • An author's (or editor's) permission flags value will be just the number 1.
  • An illustrator's permission flags will be the number 2. But some authors are also illustrators. In that case, we sum the relevant permissions values: 1 + 2 = 3.
  • A narrator's flags will be 4. In the case of an author who narrates their own work, it will be 1 + 4 = 5. If they also illustrate, it's 1 + 2 + 4 = 7.
  • A translator will have a permission value of 8. Multilingual authors would then have flags of 1 + 8 = 9. A translator who also narrates (but is not an author) would have 4 + 8 = 12.
  • If we want to have a sudo admin, having all combined permissions, we can simply use 2,147,483,647, which is the maximum safe value for a 32-bit integer.

Readers can test this logic as plain JavaScript:

  • User with permission 5 trying to edit text (permission flag 1):

Input: 5 & 1

Output: 1

  • User with permission 1 trying to narrate (permission flag 4):

Input: 1 & 4

Output: 0

  • User with permission 12 trying to narrate:

Input: 12 & 4

Output: 4

When the output is 0, we block the user; otherwise, we let them access what they are trying to access.

Permission Flag Implementation

We'll store permissions flags inside the common folder since the business logic can be shared with future modules. Let's start by adding an enum to hold some permission flags at common/middleware/common.permissionflag.enum.ts :

 export enum PermissionFlag { FREE_PERMISSION = 1, PAID_PERMISSION = 2, ANOTHER_PAID_PERMISSION = 4, ADMIN_PERMISSION = 8, ALL_PERMISSIONS = 2147483647, }

Note: Since this is an example project, we kept the flag names fairly generic.

Before we forget, now's a good time for a quick return to the addUser() function in our user DAO to replace our temporary magic number 1 with PermissionFlag.FREE_PERMISSION . We'll also need a corresponding import statement.

We can also import it into a new middleware file at common/middleware/common.permission.middleware.ts with a singleton class named CommonPermissionMiddleware :

 import express from 'express'; import { PermissionFlag } from './common.permissionflag.enum'; import debug from 'debug'; const log: debug.IDebugger = debug('app:common-permission-middleware');

Instead of creating several similar middleware functions, we'll use the factory pattern to create a special factory method (or factory function or simply factory ). Our factory function will allow us to generate—at the time of route configuration—middleware functions to check for any permission flag needed. With that, we avoid having to manually duplicate our middleware function whenever we add a new permission flag.

Here's the factory that will generate a middleware function that checks for whatever permission flag we pass it:

permissionFlagRequired(requiredPermissionFlag: PermissionFlag) { return ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { const userPermissionFlags = parseInt( res.locals.jwt.permissionFlags ); if (userPermissionFlags & requiredPermissionFlag) { next(); } else { res.status(403).send(); } } catch (e) { log(e); } }; }

Более индивидуальный случай заключается в том, что единственными пользователями, которые должны иметь доступ к определенной пользовательской записи, является тот же пользователь или администратор:

 async onlySameUserOrAdminCanDoThisAction( req: express.Request, res: express.Response, next: express.NextFunction ) { const userPermissionFlags = parseInt(res.locals.jwt.permissionFlags); if ( req.params && req.params.userId && req.params.userId === res.locals.jwt.userId ) { return next(); } else { if (userPermissionFlags & PermissionFlag.ADMIN_PERMISSION) { return next(); } else { return res.status(403).send(); } } }

Мы добавим последнюю часть промежуточного программного обеспечения, на этот раз в users.middleware.ts :

 async userCantChangePermission( req: express.Request, res: express.Response, next: express.NextFunction ) { if ( 'permissionFlags' in req.body && req.body.permissionFlags !== res.locals.user.permissionFlags ) { res.status(400).send({ errors: ['User cannot change permission flags'], }); } else { next(); } }

И поскольку приведенная выше функция зависит от res.locals.user , мы можем заполнить это значение в validateUserExists() перед вызовом next() :

 // ... if (user) { res.locals.user = user; next(); } else { // ...

На самом деле, выполнение этого в validateUserExists() сделает ненужным в validateSameEmailBelongToSameUser() . Мы можем удалить наш вызов базы данных, заменив его значением, которое мы можем рассчитывать на кэширование в res.locals :

 - const user = await userService.getUserByEmail(req.body.email); - if (user && user.id === req.params.userId) { + if (res.locals.user._id === req.params.userId) {

Теперь мы готовы интегрировать нашу логику разрешений в users.routes.config.ts .

Требование разрешений

Во-первых, мы импортируем наше новое промежуточное ПО и enum :

 import jwtMiddleware from '../auth/middleware/jwt.middleware'; import permissionMiddleware from '../common/middleware/common.permission.middleware'; import { PermissionFlag } from '../common/middleware/common.permissionflag.enum';

Мы хотим, чтобы список пользователей был доступен только по запросам, сделанным кем-то с правами администратора, но мы по-прежнему хотим, чтобы возможность создания нового пользователя была общедоступной, как и обычные ожидания UX. Давайте сначала ограничим список пользователей, используя нашу фабричную функцию перед нашим контроллером:

 this.app .route(`/users`) .get( jwtMiddleware.validJWTNeeded, permissionMiddleware.permissionFlagRequired( PermissionFlag.ADMIN_PERMISSION ), UsersController.listUsers ) // ...

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

Еще одно распространенное ограничение заключается в том, что для всех маршрутов, включающих userId , мы хотим, чтобы доступ имел только один и тот же пользователь или администратор:

 .route(`/users/:userId`) - .all(UsersMiddleware.validateUserExists) + .all( + UsersMiddleware.validateUserExists, + jwtMiddleware.validJWTNeeded, + permissionMiddleware.onlySameUserOrAdminCanDoThisAction + ) .get(UsersController.getUserById)

Мы также не позволим пользователям повышать свои привилегии, добавив UsersMiddleware.userCantChangePermission непосредственно перед ссылкой на функцию UsersController в конце каждого маршрута PUT и PATCH .

Но давайте далее предположим, что наша бизнес-логика REST API позволяет только пользователям с PAID_PERMISSION обновлять свою информацию. Это может соответствовать или не соответствовать бизнес-потребностям других проектов: это просто проверка разницы между платным и бесплатным разрешением.

Это можно сделать, добавив еще один вызов генератора после каждой из ссылок userCantChangePermission , которые мы только что добавили:

 permissionMiddleware.permissionFlagRequired( PermissionFlag.PAID_PERMISSION ),

После этого мы готовы перезапустить Node.js и попробовать его.

Ручное тестирование разрешений

Для проверки маршрутов попробуем получить GET пользователей без токена доступа:

 curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

Мы получаем ответ HTTP 401, потому что нам нужно использовать действительный JWT. Давайте попробуем с токеном доступа из нашей предыдущей аутентификации:

 curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

На этот раз мы получаем HTTP 403. Наш токен действителен, но нам запрещено использовать эту конечную точку, потому что у нас нет ADMIN_PERMISSION .

Нам не нужно, чтобы он GET нашу собственную запись пользователя:

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

Ответ:

 { "_id": "UdgsQ0X1w", "email": "[email protected]", "permissionFlags": 1, "__v": 0 }

Напротив, попытка обновить нашу собственную запись пользователя должна завершиться неудачей, поскольку наше значение разрешения равно 1 (только FREE_PERMISSION ):

 curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw '{ "firstName": "Marcos" }'

Ответ 403, как и ожидалось.

В качестве упражнения для чтения я рекомендую изменить пользовательские permissionFlags в локальной базе данных и сделать новую публикацию в /auth (чтобы сгенерировать токен с новыми permissionFlags ), а затем снова попытаться PATCH пользователя. Помните, что вам нужно будет установить для флагов числовое значение либо PAID_PERMISSION , либо ALL_PERMISSIONS , поскольку наша бизнес-логика указывает, что ADMIN_PERMISSION сам по себе не позволяет вам исправлять других пользователей или даже себя.

Требование о новом сообщении в /auth вызывает сценарий безопасности, о котором стоит помнить. Когда владелец сайта изменяет разрешения пользователя — например, чтобы попытаться заблокировать плохо себя ведущего пользователя — пользователь не увидит, что это вступит в силу до следующего обновления JWT. Это связано с тем, что проверка разрешений использует сами данные JWT, чтобы избежать дополнительного обращения к базе данных.

Такие сервисы, как Auth0, могут помочь, предлагая автоматическую ротацию токенов, но пользователи по-прежнему будут сталкиваться с непредвиденным поведением приложения в промежутках между ротациями, какими бы короткими они обычно ни были. Чтобы смягчить это, разработчики должны активно отзывать токены обновления в ответ на изменения разрешений.


При работе с REST API разработчики могут защититься от потенциальных ошибок, периодически перезапуская кучу команд cURL. Но это медленно и чревато ошибками, и быстро становится утомительным.

Автоматизированное тестирование

По мере роста API становится сложно поддерживать качество программного обеспечения, особенно при часто меняющейся бизнес-логике. Чтобы максимально уменьшить количество ошибок API и уверенно внедрять новые изменения, очень часто используется набор тестов для внешнего и/или внутреннего интерфейса приложения.

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

Работа с остатками тестовых данных

Прежде чем автоматизировать, стоит подумать о том, что происходит с тестовыми данными.

Мы используем Docker Compose для запуска нашей локальной базы данных, рассчитывая использовать эту базу данных для разработки, а не в качестве рабочего источника данных. Тесты, которые мы здесь запустим, повлияют на локальную базу данных, оставляя после себя новый набор тестовых данных каждый раз, когда мы их запускаем. В большинстве случаев это не должно быть проблемой, но если это так, мы оставляем читателям возможность изменить docker-compose.yml , чтобы создать новую базу данных для целей тестирования.

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

Мы будем использовать Mocha, Chai и SuperTest для создания наших тестов:

 npm i --save-dev chai mocha supertest @types/chai @types/express @types/mocha @types/supertest ts-node

Mocha будет управлять нашим приложением и запускать тесты, Chai обеспечит более читаемое тестовое выражение, а SuperTest облегчит сквозное (E2E) тестирование, вызывая наш API, как это сделал бы клиент REST.

Нам нужно обновить наши скрипты в package.json :

 "scripts": { // ... "test": "mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict", "test-debug": "export DEBUG=* && npm test" },

Это позволит нам запускать тесты в папке, которую мы создадим, с именем test .

Мета-тест

Чтобы опробовать нашу тестовую инфраструктуру, давайте создадим файл test/app.test.ts :

 import { expect } from 'chai'; describe('Index Test', function () { it('should always pass', function () { expect(true).to.equal(true); }); });

Синтаксис здесь может показаться необычным, но он правильный. Мы определяем тесты, expect() поведения внутри блоков it() — под которым мы подразумеваем тело функции, которую мы передаем в it() — которые вызываются внутри блоков description describe() .

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

 npm run test

Мы должны увидеть это:

 > mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict Index Test ✓ should always pass 1 passing (6ms)

Здорово! Наши тестовые библиотеки установлены и готовы к использованию.

Оптимизация тестирования

Чтобы выходные данные теста оставались чистыми, мы хотим полностью отключить ведение журнала запросов Winston во время обычных тестовых прогонов. Это так же просто, как быстрое изменение нашей ветки else без отладки в app.ts , чтобы определить, присутствует ли функция it() из Mocha:

 if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, make terse + if (typeof global.it === 'function') { + loggerOptions.level = 'http'; // for non-debug test runs, squelch entirely + } }

Последний штрих, который нам нужно добавить, — это экспорт нашего app.ts для использования в наших тестах. В конце app.ts мы добавим export default непосредственно перед server.listen() , потому что listen() возвращает наш объект Node.js http.Server .

С помощью быстрого npm run test чтобы убедиться, что мы не сломали стек, мы теперь готовы протестировать наш API.

Наш первый настоящий автоматизированный тест REST API

Чтобы начать настройку наших пользовательских тестов, давайте создадим test/users/users.test.ts , начиная с необходимых импортов и тестовых переменных:

 import app from '../../app'; import supertest from 'supertest'; import { expect } from 'chai'; import shortid from 'shortid'; import mongoose from 'mongoose'; let firstUserIdTest = ''; // will later hold a value returned by our API const firstUserBody = { email: `marcos.henrique+${shortid.generate()}@toptal.com`, password: 'Sup3rSecret!23', }; let accessToken = ''; let refreshToken = ''; const newFirstName = 'Jose'; const newFirstName2 = 'Paulo'; const newLastName2 = 'Faraco';

Далее мы создадим самый внешний блок description describe() с некоторыми определениями настройки и демонтажа:

 describe('users and auth endpoints', function () { let request: supertest.SuperAgentTest; before(function () { request = supertest.agent(app); }); after(function (done) { // shut down the Express.js server, close our MongoDB connection, then // tell Mocha we're done: app.close(() => { mongoose.connection.close(done); }); }); });

Функции, которые мы передаем в функции before() и after() , вызываются до и после всех тестов, которые мы определим, вызвав it() в одном и том же блоке describe() . Функция, переданная в after() , принимает обратный вызов, done , который, как мы гарантируем, вызывается только после того, как мы очистили и приложение, и его соединение с базой данных.

Примечание. Без нашей тактики after() Mocha будет зависать даже после успешного завершения теста. Совет часто состоит в том, чтобы просто всегда вызывать Mocha с --exit , чтобы избежать этого, но есть (часто не упоминаемый) предостережение. Если набор тестов будет зависать по другим причинам — например, из-за неправильного построения Promise в наборе тестов или в самом приложении — тогда с --exit Mocha не будет ждать и все равно сообщит об успехе, добавляя тонкое усложнение в отладку.

Теперь мы готовы добавить отдельные E2E-тесты в блок description describe() :

 it('should allow a POST to /users', async function () { const res = await request.post('/users').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.id).to.be.a('string'); firstUserIdTest = res.body.id; });

Эта первая функция создаст для нас нового пользователя — уникального, поскольку адрес электронной почты нашего пользователя был сгенерирован ранее с использованием shortid . Переменная request содержит агент SuperTest, что позволяет нам делать HTTP-запросы к нашему API. Мы делаем их с помощью await , поэтому функция, которую мы передаем it() , должна быть async . Затем мы используем expect() от Chai для проверки различных аспектов результата.

Тест npm run test на этом этапе должен показать, что наш новый тест работает.

Цепочка тестов

Мы добавим все следующие блоки it() внутрь нашего блока description describe() . Мы должны добавить их в указанном порядке, чтобы они работали с изменяемыми переменными, такими как firstUserIdTest .

 it('should allow a POST to /auth', async function () { const res = await request.post('/auth').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.accessToken).to.be.a('string'); accessToken = res.body.accessToken; refreshToken = res.body.refreshToken; });

Здесь мы получаем новый токен доступа и обновления для нашего вновь созданного пользователя.

 it('should allow a GET from /users/:userId with an access token', async function () { const res = await request .get(`/users/${firstUserIdTest}`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(200); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body._id).to.be.a('string'); expect(res.body._id).to.equal(firstUserIdTest); expect(res.body.email).to.equal(firstUserBody.email); });

Это делает запрос GET с токеном к маршруту :userId , чтобы проверить, соответствует ли ответ пользовательских данных тому, что мы изначально отправили.

Вложение, пропуск, изоляция и блокировка тестов

В Mocha блоки it() также могут содержать свои собственные блоки describe() , поэтому мы вложим наш следующий тест в другой блок describe() . Это сделает наш каскад зависимостей более четким в тестовом выводе, как мы покажем в конце.

 describe('with a valid access token', function () { it('should allow a GET from /users', async function () { const res = await request .get(`/users`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(403); }); });

Эффективное тестирование охватывает не только то, что, как мы ожидаем, сработает, но и то, что, как мы ожидаем, не сработает. Здесь мы пытаемся перечислить всех пользователей и ожидаем ответа 403, поскольку нашему пользователю (имеющему разрешения по умолчанию) не разрешено использовать эту конечную точку.

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

Mocha предоставляет некоторые функции, которые удобно использовать при разработке и отладке тестов:

  1. Метод .skip() можно использовать, чтобы избежать запуска одного теста или целого блока тестов. Когда it() заменяется на it.skip() (аналогично для description( describe() ), рассматриваемый тест или тесты не будут запущены, но будут отмечены как «ожидающие» в окончательном выводе Mocha.
  2. Для еще более временного использования .only() приводит к полному игнорированию всех тестов, не .only() , и не приводит к тому, что что-либо помечается как «ожидающее».
  3. Вызов mocha , как определено в package.json , может использовать --bail в качестве параметра командной строки. Когда это установлено, Mocha прекращает выполнение тестов, как только один тест не пройден. Это особенно полезно в нашем примере проекта REST API, поскольку тесты настроены на каскад; если только первый тест не работает, Mocha сообщает именно об этом, вместо того, чтобы жаловаться на все зависимые (но не сломанные) тесты, которые теперь терпят неудачу из-за этого.

Если мы запустим нашу полную батарею тестов на этом этапе с помощью npm run test , мы увидим три неудачных теста. (Если бы мы собирались пока оставить функции, на которые они полагаются, нереализованными, эти три теста были бы хорошими кандидатами на .skip() .)

Неудачные тесты зависят от двух частей, которые в настоящее время отсутствуют в нашем приложении. Первый находится в users.routes.config.ts :

 this.app.put(`/users/:userId/permissionFlags/:permissionFlags`, [ jwtMiddleware.validJWTNeeded, permissionMiddleware.onlySameUserOrAdminCanDoThisAction, // Note: The above two pieces of middleware are needed despite // the reference to them in the .all() call, because that only covers // /users/:userId, not anything beneath it in the hierarchy permissionMiddleware.permissionFlagRequired( PermissionFlag.FREE_PERMISSION ), UsersController.updatePermissionFlags, ]);

Второй файл, который нам нужно обновить, это users.controller.ts , так как мы только что сослались на несуществующую функцию. Нам нужно добавить import { PatchUserDto } from '../dto/patch.user.dto'; вверху и недостающая функция в классе:

 async updatePermissionFlags(req: express.Request, res: express.Response) { const patchUserDto: PatchUserDto = { permissionFlags: parseInt(req.params.permissionFlags), }; log(await usersService.patchById(req.body.id, patchUserDto)); res.status(204).send(); }

Добавление таких возможностей повышения привилегий полезно для тестирования, но не соответствует большинству реальных требований. Здесь есть два упражнения для читателя:

  1. Подумайте о том, как сделать так, чтобы код снова запрещал пользователям изменять собственные permissionFlags , но при этом позволял тестировать конечные точки с ограниченными разрешениями.
  2. Создайте и внедрите бизнес-логику (и соответствующие тесты) для определения того, как должны иметь возможность изменять флаги permissionFlags через API. (Здесь есть загадка о курице и яйце: как конкретный пользователь вообще получает разрешение на изменение разрешений?)

При этом npm run test теперь должен успешно завершиться с красиво отформатированным выводом, например:

 Index Test ✓ should always pass users and auth endpoints ✓ should allow a POST to /users (76ms) ✓ should allow a POST to /auth ✓ should allow a GET from /users/:userId with an access token with a valid access token ✓ should allow a GET from /users ✓ should disallow a PATCH to /users/:userId ✓ should disallow a PUT to /users/:userId with an nonexistent ID ✓ should disallow a PUT to /users/:userId trying to change the permission flags ✓ should allow a PUT to /users/:userId/permissionFlags/2 for testing with a new permission level ✓ should allow a POST to /auth/refresh-token ✓ should allow a PUT to /users/:userId to change first and last names ✓ should allow a GET from /users/:userId and should have a new full name ✓ should allow a DELETE from /users/:userId 13 passing (231ms)

Теперь у нас есть способ быстро убедиться, что наш REST API работает должным образом.

Отладка (с) тестов

Разработчики, столкнувшиеся с неожиданными сбоями тестов, могут легко использовать модуль отладки Winston и Node.js при запуске набора тестов.

Например, легко сосредоточиться на том, какие запросы Mongoose выполняются, вызвав DEBUG=mquery npm run test . (Обратите внимание, что в этой команде отсутствует префикс export и && в середине, из-за чего среда сохраняется для последующих команд.)

Также можно отобразить все выходные данные отладки с помощью npm run test-debug благодаря нашему более раннему дополнению к package.json .

Таким образом, у нас есть работающий, масштабируемый, поддерживаемый MongoDB REST API с удобным набором автоматизированных тестов. Но все еще не хватает некоторых предметов первой необходимости.

Безопасность (Все проекты должны носить шлем)

При работе с Express.js документация обязательна к прочтению, особенно рекомендации по безопасности. Как минимум, стоит преследовать:

  • Настройка поддержки TLS
  • Добавление промежуточного ПО для ограничения скорости
  • Обеспечение безопасности зависимостей npm (читатели могут начать с npm audit или углубиться в snyk)
  • Использование библиотеки Helmet для защиты от распространенных уязвимостей безопасности

Этот последний пункт легко добавить в наш пример проекта:

 npm i --save helmet

Затем в app.ts нам нужно только импортировать его и добавить еще один app.use() :

 import helmet from 'helmet'; // ... app.use(helmet());

Как указано в документах, Helmet (как и любое дополнение к безопасности) не является серебряной пулей, но любая профилактика помогает.

Содержащий наш проект REST API с Docker

В этой серии мы не углублялись в контейнеры Docker, но использовали MongoDB в контейнере с Docker Compose. Читатели, незнакомые с Docker, но желающие попробовать следующий шаг, могут создать файл с именем Dockerfile (без расширения) в корне проекта:

 FROM node:14-slim RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY . . RUN npm install EXPOSE 3000 CMD ["node", "./dist/app.js"]

Эта конфигурация начинается с официального образа node:14-slim от Docker, а затем создает и запускает наш пример REST API в контейнере. Конфигурация может меняться от случая к случаю, но эти общие настройки по умолчанию подходят для нашего проекта.

Чтобы создать образ, мы просто запускаем это в корне проекта (заменяя tag_your_image_here по желанию):

 docker build . -t tag_your_image_here

Затем один из способов запустить нашу серверную часть — предполагая точно такую ​​же замену текста — это:

 docker run -p 3000:3000 tag_your_image_here

На данный момент MongoDB и Node.js могут использовать Docker, но мы должны запускать их двумя разными способами. Мы оставляем читателю в качестве упражнения добавить основное приложение Node.js в docker-compose.yml , чтобы все приложение можно было запустить с помощью одной команды docker-compose .

Дополнительные навыки REST API для изучения

В этой статье мы значительно улучшили наш REST API: мы добавили контейнерную базу данных MongoDB, настроили Mongoose и экспресс-валидатор, добавили аутентификацию на основе JWT и гибкую систему разрешений, а также написали набор автоматических тестов.

Это надежная отправная точка как для новых, так и для продвинутых внутренних разработчиков. Тем не менее, в некоторых отношениях наш проект может быть не идеальным для использования в производственной среде, масштабирования и обслуживания. Помимо упражнений для чтения, которые мы разбросали по всей этой статье, что еще можно узнать?

На уровне API мы рекомендуем прочитать о создании спецификации, совместимой с OpenAPI. Читатели, особенно заинтересованные в развитии предприятий, также захотят попробовать NestJS. Это еще одна платформа, созданная поверх Express.js, но она более надежная и абстрактная, поэтому хорошо использовать наш пример проекта, чтобы сначала освоиться с основами Express.js. Не менее важно и то, что подход GraphQL к API широко используется в качестве альтернативы REST.

Когда дело доходит до разрешений, мы рассмотрели подход с побитовыми флагами с помощью генератора промежуточного программного обеспечения для вручную определенных флагов. Для дальнейшего удобства при масштабировании стоит заглянуть в библиотеку CASL, которая интегрируется с Mongoose. Это расширяет гибкость нашего подхода, позволяя давать краткие определения способностей, которые должен разрешать конкретный флаг, например can(['update', 'delete'], '(model name here)', { creator: 'me' }); вместо целой пользовательской функции промежуточного программного обеспечения.

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

  1. Изучите модульное тестирование, чтобы протестировать компоненты по отдельности — для этого также можно использовать Mocha и Chai.
  2. Изучите инструменты покрытия кода, которые помогают выявлять пробелы в наборах тестов, показывая строки кода, которые не выполняются во время тестирования. С помощью таких инструментов читатели могут дополнять примеры тестов по мере необходимости, но они могут не раскрывать все отсутствующие сценарии, например, могут ли пользователи изменять свои разрешения с помощью PATCH на /users/:userId .
  3. Попробуйте другие подходы к автоматизированному тестированию. Мы использовали интерфейс expect в стиле разработки, управляемой поведением (BDD), от Chai, но он также поддерживает should() и assert . Также стоит изучить другие библиотеки тестирования, такие как Jest.

Помимо этих тем, наш Node.js/TypeScript REST API готов к использованию. В частности, читатели могут захотеть реализовать больше промежуточного программного обеспечения, чтобы обеспечить общую бизнес-логику вокруг ресурса стандартных пользователей. Я не буду углубляться в это здесь, но я был бы рад дать рекомендации и советы читателям, которые оказались заблокированными — просто оставьте комментарий ниже.

Полный код этого проекта доступен в виде репозитория GitHub с открытым исходным кодом.


Дальнейшее чтение в блоге Toptal Engineering:

  • Использование маршрутов Express.js для обработки ошибок на основе обещаний