Angularで認可制御(同時上映: Jasmineによるユニットテスト)

はじめに

この記事は、Qrunchに投稿した記事1) 2)を、はてなブログ向けに再編集したものです。

アプリケーションのコンテンツをユーザに開示するかどうかを制御する仕組みの実装方法と、そのテストに関する手法を取り上げます。

認証と認可

よく混同される概念ですが、認証とは「その人が誰なのかを確認すること」、(狭義の)認可とは「リソースに対してアクセス権を与えること」です。

たとえばログイン処理のように、ユーザIDとパスワードを入力させて、『この人は鈴木一郎さんに違いない!』という判定を下すのが認証。

『この人は管理職ロールを持っているから、××機能に対するアクセスを許可しよう』とか、『キャンペーン期間中だけ特設ページへのアクセスを許可しよう』という仕掛けが認可です。

前提知識

  • @angular/cli の基本的な使い方がわかること
  • Observable (rxjs) に関して基礎的な知識があること
  • Component や Service を自作できること
  • Routingを利用して簡単な遷移を実装できること

開発環境

  • Angular CLI: 7.1.4
  • Node: 10.14.1
  • OS: win32 x64
  • Angular: 7.1.4

Guardについて

認可にはGuardを使おう

Angular には、「対象のページをユーザに見せてもよいか」を判定し、ついでに判定結果によってアクセスを制御するために Guard という仕組みが用意されています(Angular で構築したシステムは Single Page Application になりますが、本記事では便宜上、Router によってパスに紐づけられたコンポーネントをページと呼ぶことにします)。

※ Angular の Guard を用いると、この他にも多様な振る舞いを実現できますが、今回は話をシンプルにするために「ページに対するアクセス制御」という一点に的を絞ってお話しします。

最初に覚えておきたいポイントは以下4点です。

  1. 遷移先のページへのアクセス可否を制御できる
  2. Guard は遷移時だけでなく、URLを直叩きした際も有効になる
  3. ページごとに異なる Guard を設定できる
  4. 否認時には別のページに飛ばすことができる

では、さっそく Guard を作ってみましょう。

Guard の作り方

@angular/cli を利用している場合、ターミナルに以下のコマンドを入力するだけで Guard のボイラープレートが自動生成されます。

$ ng g guard [Guard名]

たとえば、$ ng g guard Auth と入力すると、以下のファイルがプロジェクトに追加されます。

  • <project root>
    • node-modules
    • src
      • app
        • auth.guard.ts | spec.ts
        • app.component.ts | html | css | spec.ts
        • app.module.ts
        • app-routing.module.ts
      • main.ts
      • index.html

また、ソースには予め以下の内容がコーディングされています。

src/app/auth.guard.ts

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {
  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    return true;
  }
}

CanActivate インタフェースを実装している点以外は、何の変哲もない Service クラスであることが分かります。

少しごちゃごちゃしていますが、まず初めに抑えておくべきポイントは3つです。

  1. 認可判定の本体は canActive メソッドである
  2. canActivetrue(認可)かfalse(否認)のどちらかを返す(※ 後半に補足があります)
  3. falseの場合、ページへのアクセスは自動で遮断される

初期状態では、常に認可を与える(常にtrueを返す)実装になっています。

Guard の組み込み方

作成した Guard を Route に設定してみましょう。 前提として、page1 というコンポーネントが既に存在し(下図)、さらに app-routing.module がセットアップされているものとします(下図)。

  • <project root>
    • node-modules
    • src
      • app
        • page1
          • page1.component.ts | html | css | spec.ts
        • auth.guard.ts | spec.ts
        • app.component.ts | html | css | spec.ts
        • app.module.ts
        • app-routing.module.ts
      • main.ts
      • index.html

典型的な Angular アプリケーションの場合、Route はpathcomponentの2つのプロパティを持ちますが、Guard を組み込む場合はさらに canActivate というプロパティが必要です。

src/app/app-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Page1Component } from './page1/page1.component';
import { AuthGuard } from './auth.guard';

const routes: Routes = [
  { path: 'page1', component: Page1Component, canActivate: [AuthGuard]  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

なお、canActivate プロパティには複数の Guard を指定できます。 その場合は、全ての Guard が true の場合のみ、当該ページへのアクセスが許可されます。

ここまでできたら、試しにcanActivateメソッドの戻り値を false に変更してhttp://localhost:4200/page1にアクセスしてみましょう。 アクセスが遮断されるはずです。

src/app/auth.guard.ts(抜粋)

export class AuthGuard implements CanActivate {
  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    return false; // 変更
  }
}

他のページから遷移する場合だけでなく、URL を直叩きしたときにも Guard は有効になります。

canActive の引数

canActivate メソッドには、next: ActivatedRouteSnapshotstate: RouterStateSnapshot という2つのパラメータが引き渡されてきます。

  1. ActivatedRouteSnapshotは、遷移先の Route の状態を表すイミュータブルオブジェクトです。 URLのセグメント情報やクエリパラメータ、フラグメントパラメータを取得できます。
  2. RouterStateSnapshot は、遷移先の Router の状態を表すイミュータブルオブジェクトです。 コンポーネントの配置や表示情報を表現する木構造であり、フレームワークが UI を構築するために内部的に利用する情報です。 正直、有効な使いどころをぼくは知りません。

以下は、クエリパラメータに基づいた認可処理を行うサンプルです。

src/app/auth.guard.ts

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private router: Router) { }

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    // クエリパラメータの id が '1' の場合のみ認可。
    // それ以外は、エラーページに強制遷移。
    const id = next.queryParamMap.get('id');
    if (id !== '1') {
      this.router.navigate(['/error']);
      return false;
    }
    return true;
  }
}
canActive の戻り値

先ほどは、話を簡単にするために「canActivatebooleantruefalseのいずれか)を返す」と書きました。

ですが実際には、canActivate の戻り値は boolean のほかに、 Observable<boolean> 型または Promise<boolean> 型のインスタンスを返すこともできます。

後二者は、非同期処理の解決を待ち合わせて遷移可否を判断する場合に用います(たとえばサーバと非同期通信した結果を利用したい場合などが一般的なユースケースでしょう)。

以下は、1,000ミリ秒後に true となる Observable<boolean> を返す Guard です。

src/app/auth.guard.ts

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {
  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    return of(true).pipe(delay(1000));
  }
}
否認時に別のページに飛ばす

Guard が認可を与えない場合に、任意のパス(ログインページ等)へ強制的に飛ばすといったインタラクションも可能です。

src/app/auth.guard.ts(抜粋)

export class AuthGuard implements CanActivate {
  constructor(private router: Router) { } // Router を DI

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    if ( /* 略 */ ) {
      this.router.navigate(['/login']);  // ログインページに飛ばして
      return false; // falseを返す
    }
    return true;
  }
}

適当なまとめ(なぜこんなものが必要か)

人によっては、「わざわざ Guard などといった新たな概念を利用しなくても、アクセス制御なんてComponent 内の初期化処理(constructorngOnInit等)で行えばよいじゃないか」と思われるかも知れません。

直感的な議論になりますが、ページ遷移が完了した後で認可処理が走るのはそもそも順番がおかしいです。 例えて言うなら「ノックもせずにドアを開けて、既に中に人がいたら諦める」ようなもので、いまいちお行儀が良くありません。 ムダな処理が実行されてユーザビリティも悪化します。

また、認可処理の振る舞いは、どちらかというと全体のポリシに関わる話であり、Component が個別に意識すべき関心事ではありません。 十歩譲って、必要な Component 全てに認可処理を埋め込む場合、「ngOnInit に共通処理のおまじないコードを横展開する」という拷問が待ち受けています。開発者は泣きます。

せっかく便利な仕掛けがあるのだから、オレオレ実装などせず、ベストプラクティスに従って気持ちよくコードを書きたいですね。



単体テスト

ここからは、怒涛の単体テスト編です。

前提知識

  • 言語を問わず、単体テスト(自動テスト)について、基礎的な理解がある
  • Angular の DIProvider について、基礎的な理解がある

単体テストについて

この記事における単体テストもしくはユニットテストとは、アプリケーションを構成するプログラムの最小単位ごとに実施するテストを指します。

念のために付け加えておくと、「ユーザの操作シナリオを手動で実行し、その結果が期待通りかどうかを目視で照合する行為」のことではありません。

Jasmine について

Angular では、標準で Jasmine というテスティングフレームワークを利用できます。

Jasmine については、Qiita にあるこちらの記事が詳しいです。

改めて先ほどの Guard を眺めてみると、Router サービスが DI (依存性注入)され、さらに、内部ロジックは ActivatedRouteSnapshot に依存した実装がなされている点が少々厄介です。

テストの対象はあくまで AuthGuard クラスですので、テストコードを書く際には、それら無関係な外部サービスをことごとく切り離す(全てダミー実装のスタブに置き換える)必要があります。

Guard のテストコード

ここでは、以下 2 点を検証するコードを書いてみましょう。

  1. ActivatedRouteSnapshotqueryParamMap の内容に応じて認可判定を行っていること
    • クエリパラメータの id1 の場合、canActivate メソッドは true を返すこと(認可)
    • クエリパラメータの id1 以外(例えば null)の場合、canActivate メソッドは false を返すこと(否認)
  2. 否認の場合は Routernavigate に正しいパラメータ(/error)を引き渡していること

src/app/auth.guard.spec.ts

import { TestBed, inject } from '@angular/core/testing';
import { AuthGuard } from './auth.guard';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';

describe('AuthGuard', () => {
  // クエリパラメータのスタブ
  const queryParams = { id: null };

  // ルータのスタブ
  const router = jasmine.createSpyObj('router', ['navigate']);

  beforeEach(() => {
    queryParams.id = null;
    TestBed.configureTestingModule({
      providers: [
        AuthGuard,
        {
          provide: ActivatedRouteSnapshot,
          useValue: { queryParamMap: { get: () => queryParams.id } }
        },
        {
          provide: Router,
          useValue: router
        }
      ]
    });
  });

  it('クエリパラメータの id が 1 のとき、認可',
    inject([AuthGuard, ActivatedRouteSnapshot], (guard: AuthGuard, ars: ActivatedRouteSnapshot) => {
    queryParams.id = '1';
    expect(guard.canActivate(ars, <RouterStateSnapshot>{})).toBe(true);
  }));

  it('クエリパラメータの id が null のとき、error に飛ばす', inject([AuthGuard, ActivatedRouteSnapshot], (guard: AuthGuard, ars: ActivatedRouteSnapshot) => {
    queryParams.id = null;
    expect(guard.canActivate(ars, <RouterStateSnapshot>{})).toBe(false);
    expect(router.navigate).toHaveBeenCalledWith(['/error']);
  }));
});

ここまで書けたら、ターミナルに以下のコマンドを打ち込みます。

$ ng test

テストが実行され、どちらも SUCCESS になるはずです。

めでたし。

まとめ

正直なところ、Angular のテストコードは書くのがやたら面倒なので、ついサボりたくなります。

プロダクトコードの実装工数よりもテストコードの作成工数の方が大きいことも多々ありますし、いろいろ差し迫ってくると「いま明らかに正常動作しているもののためにテストコードを書くなんてアホらしい」という気持ちに駆られることもあります。

よい子のみなさんは、決してそんな邪な心を持たずピュアでクリスタルな心を持ってテストを書いてください。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.