前回からの続きです。
今回は Item API に対してe2eテストを作成しますが、e2eテストを実行するたびに正式なトークンをとってくるのは大変なので、トークンの検証部分をモックして e2eテストを実行します。
- NestJSで作成したAPIにAuth0の認証/認可を追加する 〜準備編〜
- NestJSで作成したAPIにAuth0の認証/認可を追加する 〜準備編 その2〜
- NestJSで作成したAPIにAuth0の認証/認可を追加する 〜認証編〜
- NestJSで作成したAPIにAuth0の認証/認可を追加する 〜認可(Auth0)編〜
- NestJSで作成したAPIにAuth0の認証/認可を追加する 〜認可(NestJS)編〜
おさらい
NestJSのGuardやPassport、Auth0を活用し Item APIにJWTによる認証認可を追加しました。
Item API は以下のAPIで構成され、GET以外のメソッドはすべて認証が必要な状態です。
GET /items
GET /items/:itemId
POST /items
PATCH /items/:itemId
DELETE /items/:itemId
ソースコードは以下です。
GitHub - popy1017/nest_with_auth0 at v1.4.0
流れ
- テスト用のMockStrategyを作成する(
mock-jwt.strategy.ts
) - テスト実行時に本番用のStrategyをテスト用のMockStrategyでoverrideする
- テスト内でMockStrategy用のJWTを作成し、認証・認可が必要なAPIのテストを実行する
e2eテストを作成する
準備
e2eテストを作り始める前に、テスト実行時にimport パスが絶対パス指定となっている(import { Hoge } from 'src/xxx/Hoge.xxx.ts'
など)ファイルを読み込むために、以下の設定を行います。
// test/jest-e2e.json { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, // 以下を追加 "moduleNameMapper": { "^src/(.*)": "<rootDir>/../src/$1" } }
上記設定を行わないと、以下のようなエラーが出てしまいます。
FAIL test/items.e2e-spec.ts ● Test suite failed to run Cannot find module 'src/item-repository' from '../src/items/items.service.ts'
e2eテストを作成
NestJSのe2eテスト関連のファイルはプロジェクト作成時にtest/
に作成されています。
app.e2e-spec.ts
を参考にitems.e2e-spec.ts
を作成します。
(app.e2e-spec.ts
は使わないので削除してしまってもOK)
今回は POST /items
に対して、以下のようなレスポンスステータスを確認するテストを作成しました。
この段階ではトークンに適当な文字列を指定しているため、すべて401 Unauthorized
になってしまい 1/4 しか成功しません。
ステータス | トークン | パーミッション | リクエストボディ |
---|---|---|---|
201 Created | ○ | ○ | ○ |
400 Bad Request | ○ | ○ | ✗ |
401 Unauthorized | ✗ | ○ | ○ |
403 Forbidden | ○ | ✗ | ○ |
// test/items.e2e-spec.ts describe('POST /items', () => { const mockItem = { name: 'Item test', price: 1000, }; it('should return 201 if request has valid token and permisson.', () => { return request(app.getHttpServer()) .post('/items') .set('Authorization', 'token') .send(mockItem) .expect(HttpStatus.CREATED); }); it('should return 400 if request has invalid request body.', () => { return request(app.getHttpServer()) .post('/items') .set('Authorization', 'token') .send(mockItem) .expect(HttpStatus.BAD_REQUEST); }); it('should return 401 if request has invalid token.', () => { return request(app.getHttpServer()) .post('/items') .set('Authorization', 'token') .send(mockItem) .expect(HttpStatus.UNAUTHORIZED); }); it('should return 403 if request does not have enough permission.', () => { return request(app.getHttpServer()) .post('/items') .set('Authorization', 'token') .send(mockItem) .expect(HttpStatus.FORBIDDEN); }); });
テスト用のMockStrategyの作成
以下のように、MockJwtStrategyを作成します。
secretOrKeyのNESTJS_MOCK_JWT_SECRET
は適当に決めておきます。
import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() export class MockJwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ secretOrKey: process.env.NESTJS_MOCK_JWT_SECRET, jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), }); } validate(payload: unknown): unknown { return payload; } }
MockStrategyでoverrideする
e2eテスト実行時にJwtStrategy(本番用)をoverrideするには下記のようにします。
describe('ItemsController (e2e)', () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], providers: [ ItemRepository, ], }) // JwtStrategy(本番用)をoverride .overrideProvider(JwtStrategy) .useClass(MockJwtStrategy) .compile(); app = moduleFixture.createNestApplication(); await app.init(); });
MockStrategy用のJWTを作成する
Authentication | NestJS - A progressive Node.js framework
パッケージのインストール
$ npm install --save @nestjs/jwt passport-jwt $ npm install --save-dev @types/passport-jwt
JwtServiceをInject
他のServiceなどと同様に、テストモジュールにJwtService
をInjectして、変数に格納します。
describe('ItemsController (e2e)', () => { let app: INestApplication; let jwtService: JwtService; // <= 追加 beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], providers: [ItemRepository, JwtService], }) .overrideProvider(JwtStrategy) .useClass(MockJwtStrategy) .compile(); app = moduleFixture.createNestApplication(); jwtService = moduleFixture.get<JwtService>(JwtService); // <= 追加 await app.init(); });
トークンの生成
jwtService.sign({Payload},{Option})
で、JWTを生成します。
{Payload}
の中にはRBACの実装で設定したPermissionの値が必要であるため、必要なPermissionを記載します。
{Option}
にはMockJwtStrategyで指定したsecretの値が必要になります。
it('should return 200 if request has valid token and permisson.', () => { const token = jwtService.sign( { permissions: ['create:items'] }, // <= Payloadを指定 { secret: process.env.NESTJS_MOCK_JWT_SECRET }, // <= secretを指定 ); return request(app.getHttpServer()) .post('/items') .set('Authorization', `Bearer ${token}`) // Authorizationヘッダにセット .send(mockItem) .expect(HttpStatus.CREATED); });
テストコードの修正
最後に、トークン生成などの処理を各テストに入れ込みます。
コードは以下のようになりました。
class-validatorによるバリデーションを有効にするために途中
app.useGlobalPipes(new ValidationPipe());
を追加しました。
describe('ItemsController (e2e)', () => { let app: INestApplication; let jwtService: JwtService; const getToken = (payload: object, secret?: string) => { return jwtService.sign(payload, { secret: secret ?? process.env.NESTJS_MOCK_JWT_SECRET, }); }; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], providers: [ItemRepository, JwtService], }) .overrideProvider(JwtStrategy) .useClass(MockJwtStrategy) .compile(); app = moduleFixture.createNestApplication(); jwtService = moduleFixture.get<JwtService>(JwtService); app.useGlobalPipes(new ValidationPipe()); await app.init(); }); describe('POST /items', () => { const mockItem = { name: 'Item test', price: 1000, }; it('should return 201 if request has valid token and permisson.', () => { const token = getToken({ permissions: ['create:items'] }); return request(app.getHttpServer()) .post('/items') .set('Authorization', `Bearer ${token}`) .send(mockItem) .expect(HttpStatus.CREATED); }); it('should return 400 if request has invalid request body.', () => { const token = getToken({ permissions: ['create:items'] }); const invalidItem = Object.assign(mockItem); invalidItem.name = '123456789101'; // 文字数オーバー return request(app.getHttpServer()) .post('/items') .set('Authorization', `Bearer ${token}`) .send(invalidItem) .expect(HttpStatus.BAD_REQUEST); }); it('should return 401 if request has invalid token.', () => { // 異なる secret を指定 const token = getToken({ permissions: ['create:items'] }, 'InvalidToken'); return request(app.getHttpServer()) .post('/items') .set('Authorization', `Bearer ${token}`) .send(mockItem) .expect(HttpStatus.UNAUTHORIZED); }); it('should return 403 if request does not have enough permission.', () => { // パーミッション不足 const token = getToken({ permissions: [] }); return request(app.getHttpServer()) .post('/items') .set('Authorization', `Bearer ${token}`) .send(mockItem) .expect(HttpStatus.FORBIDDEN); }); }); });
以上で完了です。
npm run test:e2e
を実行してみると、すべてのテストが通ることが確認できました!
$ npm run test:e2e > nest_with_auth0@0.0.1 test:e2e > jest --config ./test/jest-e2e.json PASS test/items.e2e-spec.ts ItemsController (e2e) POST /items ✓ should return 201 if request has valid token and permisson. (173 ms) ✓ should return 400 if request has invalid request body. (15 ms) ✓ should return 401 if request has invalid token. (12 ms) ✓ should return 403 if request does not have enough permission. (10 ms) Test Suites: 1 passed, 1 total Tests: 4 passed, 4 total Snapshots: 0 total Time: 2.533 s, estimated 3 s Ran all test suites.