構建 Node.js/TypeScript REST API,第 3 部分:MongoDB、身份驗證和自動化測試

已發表: 2022-03-11

在我們關於如何使用 Express.js 和 TypeScript 創建 Node.js REST API 系列的這一點上,我們已經構建了一個工作後端並將我們的代碼分離為路由配置、服務、中間件、控制器和模型。 如果您已準備好從那裡開始,請克隆示例 repo 並運行git checkout toptal-article-02

具有 Mongoose、身份驗證和自動化測試的 REST API

在第三篇也是最後一篇文章中,我們將通過添加以下內容繼續開發我們的 REST API:

  • Mongoose允許我們使用 MongoDB 並將我們的內存 DAO 替換為真實的數據庫。
  • 身份驗證和權限功能,因此 API 使用者可以使用 JSON Web 令牌 (JWT) 安全地訪問我們的端點。
  • 使用 Mocha(一個測試框架)、Chai(一個斷言庫)和 SuperTest(一個 HTTP 抽像模塊)進行自動化測試,以幫助檢查代碼庫增長和變化時的回歸。

在此過程中,我們將添加驗證和安全庫,獲得一些使用 Docker 的經驗,並建議讀者在構建和擴展他們自己的 REST API 時可以很好地探索的幾個進一步的主題、庫和技能。

將 MongoDB 作為容器安裝

讓我們首先將上一篇文章中的內存數據庫替換為真實數據庫。

要為開發創建本地數據庫,我們可以在本地安裝 MongoDB。 但是環境之間的差異(例如操作系統發行版和版本)可能會出現問題。 為了避免這種情況,我們將利用這個機會利用行業標準工具:Docker 容器。

讀者唯一需要做的就是安裝 Docker,然後安裝 Docker Compose。 安裝後,在終端中運行docker -v應該會產生一個 Docker 版本號。

現在,要運行 MongoDB,我們將在項目的根目錄中創建一個名為docker-compose.yml的 YAML 文件,其中包含以下內容:

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

Docker Compose 允許我們使用一個配置文件一次運行多個容器。 在本文的最後,我們將看看在 Docker 中運行我們的 REST API 後端,但現在,我們將只使用它來運行 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目錄,因此我們應該在.gitignore中添加一個data行。

現在,如果我們需要關閉 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 通信,我們的後端將利用一個名為 Mongoose 的對像數據建模 (ODM) 庫。 雖然 Mongoose 非常易於使用,但值得查看文檔以了解它為實際項目提供的所有高級可能性。

要安裝 Mongoose,我們使用以下命令:

 npm i mongoose

讓我們配置一個 Mongoose 服務來管理與 MongoDB 實例的連接。 由於該服務可以在多個資源之間共享,因此我們將其添加到項目的common文件夾中。

配置很簡單。 雖然不是嚴格要求,但我們將有一個mongooseOptions對象來自定義以下 Mongoose 連接選項:

  • useNewUrlParser :如果不將此設置為true ,Mongoose 會打印出棄用警告。
  • useUnifiedTopology :Mongoose 文檔建議將此設置為true以使用更新的連接管理引擎。
  • serverSelectionTimeoutMS :為了這個演示項目的用戶體驗,比默認的 30 秒更短的時間意味著任何忘記在 Node.js 之前啟動 MongoDB 的讀者將更快地看到關於它的有用反饋,而不是明顯無響應的後端.
  • useFindAndModify :將此設置為false也可以避免棄用警告,但它在文檔的棄用部分中提到,而不是在 Mongoose 連接選項中。 更具體地說,這會導致 Mongoose 使用更新的本機 MongoDB 功能,而不是舊的 Mongoose shim。

將這些選項與一些初始化和重試邏輯相結合,這是最終的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();

請務必明確 Mongoose 的connect()函數和我們自己的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類定義中刪除除構造函數之外的所有內容。 我們可以通過在構造函數之前為 Mongoose 創建用戶Schema來開始填充它:

 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 集合併添加了我們的內存數據庫沒有的特殊功能: password字段中的select: false將在我們獲取用戶或列出所有用戶時隱藏該字段。

我們的用戶模式可能看起來很熟悉,因為它類似於我們的 DTO 實體。 主要區別在於我們定義了哪些字段應該存在於我們的名為Users的 MongoDB 集合中,而 DTO 實體定義了在 HTTP 請求中接受哪些字段。

我們方法的那一部分沒有改變,因此仍然在users.dao.ts的頂部導入我們的三個 DTO。 但在實現我們的 CRUD 方法操作之前,我們將以兩種方式更新我們的 DTO。

DTO 變更 1: id_id

因為 Mongoose 會自動提供_id字段,所以我們將從 DTO 中刪除id字段。 無論如何,它將來自路由請求的參數。

請注意,Mongoose 模型默認提供虛擬id getter,因此我們在上面使用{ id: false }禁用了該選項以避免混淆。 但這破壞了我們在用戶中間件validateSameEmailBelongToSameUser()中對user.id的引用——我們需要user._id代替。

一些數據庫使用約定id ,而另一些使用_id ,因此沒有完美的接口。 對於我們使用 Mongoose 的示例項目,我們只是注意了我們在代碼中的哪個位置使用了哪個,但不匹配仍然會暴露給 API 使用者:

五種請求類型的路徑: 1. 對 /users 的非參數化 GET 請求通過 listUsers() 控制器並返回一個對像數組,每個對像都有一個 _id 鍵。 2. 對 /users 的非參數化 POST 請求通過 createUser() 控制器,該控制器使用新生成的 ID 值,將其返回到帶有 id 鍵的對像中。 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 配置,該配置通過 Express.js 填充 req.params.userId,然後是設置 res.locals.jwt.userId 的 validJWTNeeded() 中間件,然後是其他中間件函數(使用 req. params.userId、res.locals.jwt.userId 或兩者;和/或執行 MongoDB 查找並使用 result._id),最後通過將使用 req.body.id 並返回不返回正文或返回的 UsersController 函數帶有 _id 鍵的對象。
在整個 REST API 項目中使用和公開用戶 ID。 請注意,各種內部約定意味著用戶 ID 數據的不同來源:直接請求參數、JWT 編碼數據或新獲取的數據庫記錄。

我們將其作為練習留給讀者,以在項目結束時實施許多現實世界的解決方案之一。

DTO 變更 2:為基於標誌的權限做準備

我們還將在 DTO 中將permissionFlags重命名為permissionLevel以反映我們將要實現的更複雜的權限系統,以及上面的 Mongoose userSchema定義。

DTO:DRY 原則呢?

請記住,DTO 只包含我們希望在 API 客戶端和數據庫之間傳遞的字段。 這可能看起來很不幸,因為模型和 DTO 之間存在一些重疊,但請注意不要以“默認安全”為代價對 DRY 施加過多的壓力。 如果添加一個字段只需要在一個地方添加,開發人員可能會在 API 中無意中將其公開,而該 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 使用者通過userFieldspermissionFlags發送什麼,我們都會用值1覆蓋它。

接下來我們閱讀了通過 ID 獲取用戶、通過電子郵件獲取用戶以及通過分頁列出用戶的基本功能:

 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()函數可以更新整個文檔或只是其中的一部分。 請注意,我們自己的函數將使用 TypeScript 聯合類型(由|表示)將userFields作為PatchUserDtoPutUserDto

 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時,我們保持了我們在上一篇文章中創建的結構。 但為什麼會有例外?

  • 正如我們上面所暗示的,我們對PUTPATCH都使用了updateUserById() 。 (如第 2 部分所述,我們遵循典型的 REST API 實現,而不是試圖嚴格遵守特定的 RFC。除其他外,這意味著如果PUT請求不存在,則不會創建新實體;這樣,我們的後端不會將 ID 生成的控制權交給 API 消費者。)
  • 我們將limitpage參數傳遞給getUsers()因為我們的新 DAO 實現將使用它們。

這裡的主要結構是一個相當健壯的模式。 例如,如果開發人員想要將 Mongoose 和 MongoDB 換成 TypeORM 和 PostgreSQL 之類的東西,它可以被重用。 如上所述,這種替換只需要重構 DAO 的各個功能,同時保持它們的簽名以匹配其餘代碼。

測試我們的 Mongoose 支持的 REST API

讓我們使用npm start API 後端。 然後我們將嘗試創建一個用戶:

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

響應對象包含一個新的用戶 ID:

 { "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" }

Mongoose 還使用一個特殊字段__v進行版本控制; 每次更新此記錄時都會遞增。

接下來,讓我們列出用戶:

 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 響應。

讀者可能想知道密碼字段是否正常工作,因為我們在 Mongoose Schema定義中的select: false按預期將它從我們的GET輸出中隱藏了起來。 讓我們重複我們最初的POST以再次創建一個用戶,然後檢查。 (不要忘記存儲新 ID 以備後用。)

使用 MongoDB 容器進行隱藏密碼和直接數據調試

要檢查密碼是否安全存儲(即散列,而不是純文本),開發人員可以直接檢查 MongoDB 數據。 一種方法是從正在運行的 Docker 容器中訪問標準的mongo CLI 客戶端:

 sudo docker exec -it toptal-rest-series_mongo_1 mongo

從那裡,執行use api-db後跟db.users.find().pretty()將列出所有用戶數據,包括密碼。

喜歡 GUI 的用戶可以安裝單獨的 MongoDB 客戶端,例如 Robo 3T:

左側邊欄顯示數據庫連接,每個連接都包含數據庫、函數和用戶等事物的層次結構。主窗格具有用於運行查詢的選項卡。當前選項卡通過查詢“db.getCollection('users').find({})”連接到 localhost:27017 的 api-db 數據庫,得到一個結果。結果有四個字段:_id、密碼、電子郵件和 __v。密碼字段以 "$argon2$i$v=19$m=4096,t=3,p=1$" 開頭,以 salt 和 hash 結尾,以美元符號分隔並以 base 64 編碼。
使用 Robo 3T 直接檢查 MongoDB 數據。

密碼前綴 ( $argon2... ) 是 PHC 字符串格式的一部分,它是有意存儲的,未經修改:提到 Argon2 及其一般參數的事實不會幫助黑客確定原始密碼,如果他們設法竊取數據庫。 存儲的密碼可以使用加鹽進一步加強,我們將在下面使用 JWT 的一種技術。 我們將其作為練習留給讀者應用上面的加鹽並檢查兩個用戶輸入相同密碼時存儲值之間的差異。

我們現在知道 Mongoose 成功地將數據發送到我們的 MongoDB 數據庫。 但是我們怎麼知道我們的 API 消費者會在他們的請求中向我們的用戶路由發送適當的數據呢?

添加快速驗證器

有幾種方法可以完成字段驗證。 在本文中,我們將使用 express-validator,它非常穩定、易於使用且文檔齊全。 雖然我們可以使用 Mongoose 附帶的驗證功能,但 express-validator 提供了額外的功能。 例如,它帶有一個開箱即用的電子郵件地址驗證器,在 Mongoose 中需要我們編寫一個自定義驗證器。

讓我們安裝它:

 npm i express-validator

要設置我們想要驗證的字段,我們將使用我們將在users.routes.config.ts中導入的body()方法。 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,

確保在存在的任何body()行之後的每個路由中添加BodyValidationMiddleware.verifyBodyFieldsErrors ,否則它們都不會起作用。

請注意我們如何更新POSTPUT路由以使用 express-validator 而不是我們自己開發的validateRequiredUserBodyFields函數。 由於這些路由是唯一使用此函數的路由,因此可以從users.middleware.ts中刪除其實現。

而已! 讀者可以重新啟動 Node.js 並使用他們最喜歡的 REST 客戶端試用結果,看看它如何處理各種輸入。 不要忘記探索 express-validator 文檔以獲取更多可能性; 我們的示例只是請求驗證的起點。

有效數據是要保證的一方面; 有效的用戶和操作是另一個。

身份驗證與權限(或“授權”)流程

我們的 Node.js 應用程序公開了一組完整的users/端點,允許 API 使用者創建、更新和列出用戶。 但是每個端點都允許無限制的公共訪問。 這是一種常見的模式,可以防止用戶更改彼此的數據以及防止外部人員訪問我們不想公開的任何端點。

這些限制涉及兩個主要方面,它們都簡稱為“auth”。 身份驗證是關於請求來自誰,而授權是關於他們是否被允許做他們請求的事情。 重要的是要注意正在討論的是哪一個。 即使沒有簡短的表單,標準的 HTTP 響應代碼也會混淆問題: 401 Unauthorized是關於身份驗證的,而403 Forbidden是關於授權的。 我們會錯誤地選擇“auth”代表模塊名稱中的“authentication”,並使用“permissions”來處理授權問題。

即使沒有簡短的表單,標準的 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

 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'] }); }

至於確保req.body中存在emailpassword的中間件,我們稍後會在配置路由以使用上述verifyUserPassword()函數時使用 express-validator。

存儲 JWT 機密

要生成 JWT,我們需要一個 JWT 機密,我們將使用它來簽署我們生成的 JWT,並驗證來自客戶端請求的傳入 JWT。 我們不會在 TypeScript 文件中硬編碼 JWT 機密的值,而是將其存儲在一個單獨的“環境變量”文件.env中,該文件不應該被推送到代碼存儲庫中。

按照慣例,我們在 repo 中添加了一個.env.example文件,以幫助開發人員了解在創建真正的.env時需要哪些變量。 在我們的例子中,我們需要一個名為JWT_SECRET的變量,將我們的 JWT 密碼存儲為字符串。 等到本文結尾並使用 repo 的最終分支的讀者需要記住在本地更改這些值

實際項目將特別需要遵循 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/controllers/auth.controller.ts創建/auth控制器。 我們不需要在此處導入 dotenv 庫,因為在app.ts中導入它會使.env文件的內容通過名為process的 Node.js 全局對像在整個應用程序中可用:

 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-native crypto模塊生成鹽和散列,然後使用它們創建一個refreshToken ,API 使用者可以使用它刷新當前的 JWT——這是一個特別適合應用程序的設置能夠擴展。

refreshKeyrefreshTokenaccessToken什麼區別? *Token被發送給我們的 API 消費者,其想法是accessToken用於任何超出公眾可用的請求,而refreshToken用於請求替換過期的accessToken 。 另一方面, refreshKey用於將salt變量(在refreshToken中加密)傳遞回我們的刷新中間件,我們將在下面介紹。

請注意,我們的實現有 jsonwebtoken 為我們處理令牌過期。 如果 JWT 過期,客戶端將需要再次進行身份驗證。

初始 Node.js REST API 身份驗證路由

現在讓我們在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"

偉大的! 我們有訪問令牌和刷新令牌,但我們需要一些中間件來對它們做一些有用的事情。

智威湯遜中間件

我們需要一個新的 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 ,我們可以在next()調用之前在validateUserExists()中填充該值:

 // ... 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)

我們還將通過在每個PUTPATCH路由末尾的UsersController函數引用之前添加UsersMiddleware.userCantChangePermission來防止用戶升級他們的權限。

但是讓我們進一步假設我們的 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_PERMISSIONALL_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 將通過調用我們的 API 來促進端到端 (E2E) 測試,就像 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); }); });

這裡的語法可能看起來不尋常,但它是正確的。 我們通過在it()中的expect()行為來定義測試——我們的意思是我們將傳遞給it()的函數體——在describe()塊中被調用。

現在,在終端,我們將運行:

 npm run test

我們應該看到:

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

偉大的! 我們的測試庫已安裝並可以使用。

簡化測試

為了保持測試輸出乾淨,我們希望在正常測試運行期間完全靜音 Winston 請求日誌記錄。 這就像快速更改app.ts中的非調試else分支以檢測是否存在來自 Mocha 的it()函數一樣簡單:

 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的末尾,我們將在server.listen()之前添加export default ,因為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';

接下來,我們將創建一個最外層的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()的函數在我們將通過在同一個describe()塊中調用it()定義的所有測試之前和之後被調用。 傳遞給after()的函數需要一個回調done ,我們確保只有在清理了應用程序及其數據庫連接後才會調用它。

注意:如果沒有我們的after()策略,即使成功完成測試,Mocha 也會掛起。 建議通常是簡單地始終使用--exit調用 Mocha 以避免這種情況,但有一個(通常未提及)警告。 如果測試套件由於其他原因而掛起——比如測試套件中的錯誤構造的 Promise 或應用程序本身——那麼使用--exit ,Mocha 將不會等待並且無論如何都會報告成功,給調試增加了一個微妙的複雜性。

現在我們準備在describe()塊中添加單獨的 E2E 測試:

 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 代理,允許我們向我們的 API 發出 HTTP 請求。 我們使用await來製作它們,這就是我們傳遞給it()的函數必須是async的原因。 然後我們使用 Chai 的expect()來測試結果的各個方面。

此時的npm run test應該顯示我們的新測試正常工作。

測試鏈

我們將在我們的describe()塊中添加以下所有it() ) 塊。 我們必須按照顯示的順序添加它們,以便它們可以使用我們正在變異的變量,例如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); });

這會向:userId路由發出帶有令牌的GET請求,以檢查用戶數據響應是否與我們最初發送的內容匹配。

嵌套、跳過、隔離和測試測試

在 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()塊中,我們可以繼續編寫測試。 由於我們已經討論了其餘測試代碼中使用的功能,因此可以從 repo 的這一行開始找到它。

Mocha 提供了一些在開發和調試測試時可以方便使用的功能:

  1. .skip()方法可用於避免運行單個測試或整個測試塊。 當it()it.skip()替換時(對於describe()也是如此),所討論的一個或多個測試將不會運行,但在 Mocha 的最終輸出中將被計為“待處理”。
  2. 對於更臨時的使用, .only()函數會導致所有非.only()標記的測試被完全忽略,並且不會導致任何標記為“待定”。
  3. package.json中定義的mocha調用可以使用--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 的調試模塊。

例如,通過調用DEBUG=mquery npm run test可以很容易地關注執行了哪些 Mongoose 查詢。 (請注意,該命令如何在中間缺少export前綴和&& ,這將使環境持續到以後的命令。)

由於我們之前對package.json的添加,還可以使用npm run test-debug顯示所有調試輸出。

這樣,我們就有了一個工作的、可擴展的、支持 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());

正如其文檔所指出的那樣,頭盔(就像任何安全附加功能一樣)不是靈丹妙藥,但每一點預防措施都有幫助。

使用 Docker 包含我們的 REST API 項目

在本系列中,我們沒有深入探討 Docker 容器,但我們確實在 Docker Compose 的容器中使用了 MongoDB。 不熟悉 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"]

此配置從 Docker 的node:14-slim官方鏡像開始,並在容器中構建和運行我們的示例 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 和 express-validator,添加了基於 JWT 的身份驗證和靈活的權限系統,並編寫了一系列自動化測試。

對於新的和高級的後端開發人員來說,這是一個堅實的起點。 然而,在某些方面,我們的項目可能並不適合生產使用、擴展和維護。 除了我們貫穿本文的讀者練習之外,還有什麼要學習的?

在 API 級別,我們建議閱讀有關創建符合 OpenAPI 的規範的內容。 對追求企業發展特別感興趣的讀者也會想試試 NestJS。 它是建立在 Express.js 之上的另一個框架,但它更加健壯和抽象——這就是為什麼最好使用我們的示例項目先熟悉 Express.js 基礎知識。 同樣重要的是,API 的 GraphQL 方法作為 REST 的替代方案具有廣泛的吸引力。

在權限方面,我們介紹了一種按位標誌方法,其中包含用於手動定義標誌的中間件生成器。 為了在擴展時更加方便,值得研究與 Mongoose 集成的 CASL 庫。 它擴展了我們方法的靈活性,允許對特定標誌應該允許的能力進行簡潔的定義,例如can(['update', 'delete'], '(model name here)', { creator: 'me' }); 代替整個自定義中間件功能。

我們在這個項目中提供了一個實用的自動化測試跳板,但是一些重要的主題超出了我們的範圍。 我們建議讀者:

  1. 探索單元測試以單獨測試組件——Mocha 和 Chai 也可以用於此目的。
  2. 查看代碼覆蓋工具,該工具通過顯示測試期間未運行的代碼行來幫助識別測試套件中的差距。 使用這些工具,讀者可以根據需要補充示例測試——但他們可能不會揭示每個缺失的場景,例如用戶是否可以通過PATCH修改他們的權限到/users/:userId
  3. 嘗試其他自動化測試方法。 我們使用了 Chai 的行為驅動開發 (BDD) 風格的expect接口,但它也支持should()assert 。 還值得學習其他測試庫,例如 Jest。

除了這些主題之外,我們的 Node.js/TypeScript REST API 已經準備好構建。 特別是,讀者可能希望實現更多的中間件來圍繞標準用戶資源執行通用業務邏輯。 我不會在這裡深入探討,但我很樂意為發現自己被屏蔽的讀者提供指導和提示——只需在下方發表評論即可。

該項目的完整代碼可作為開源 GitHub 存儲庫獲得。


進一步閱讀 Toptal 工程博客:

  • 使用 Express.js 路由進行基於 Promise 的錯誤處理