ぽぴなび

知って感動した技術情報・生活情報や買ってよかったものの雑記です。

【NestJS】認証部分をモックしてe2eテストを作成する

前回からの続きです。
今回は Item API に対してe2eテストを作成しますが、e2eテストを実行するたびに正式なトークンをとってくるのは大変なので、トークンの検証部分をモックして e2eテストを実行します。

  1. NestJSで作成したAPIにAuth0の認証/認可を追加する 〜準備編〜
  2. NestJSで作成したAPIにAuth0の認証/認可を追加する 〜準備編 その2〜
  3. NestJSで作成したAPIにAuth0の認証/認可を追加する 〜認証編〜
  4. NestJSで作成したAPIにAuth0の認証/認可を追加する 〜認可(Auth0)編〜
  5. 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

流れ

  1. テスト用のMockStrategyを作成する(mock-jwt.strategy.ts
  2. テスト実行時に本番用のStrategyをテスト用のMockStrategyでoverrideする
  3. テスト内で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.