LoginSignup
2
1

More than 1 year has passed since last update.

神様ごめんなさい、私はテストのためにAuthGuardをいじります

Posted at

新卒エンジニア、3ヶ月目。テストを書いています。私がいま担当しているプロジェクトで開発しているWebアプリではAzure Active Directoryを認可サーバーとして使っているんですが、CypressでE2Eテストを行おうとするとどうしてもこの認可プロセスがうまく行かない。おそらくもう少しディグればエレガントな解決策が出てきそうではあるけど、それでもTwo-factor Authenticationは恐らく自動化できない。なので、シニアエンジニアにも相談したところ、苦肉の策ではありますがクライアントアプリの認可プロセスをいじってしまう邪道の実装をすることになりました。

フレームワークはAngular。既存のAuthGuardにもう一つ、テストを通すためのAuthGuardを実装します。
AngularのRouteは配列として複数のAuthGuardを持ち、はじめの要素を優先するように出来ていますが、私が公式ドキュメントを読む限り、基本的にはANDロジックであり、ORロジックには向いていない。つまり、すべてのAuthGuardがTrueを返さない限りルート先に飛べません。テストではそもそも既存のAuthGuardが通りません。

この解決策のひとつとして考えられるのが複数のAuthGuardを管理する親AuthGuardを実装することです。
つまりこう。

@Injectable({
  providedIn: 'root'
})
export class ParentAuthGuard implements CanActivate, CanLoad {
  private activatedRoute: ActivatedRouteSnapshot;
  private state: RouterStateSnapshot;
  private executor: 'canActivate' | 'canActivateChild' | 'canLoad';
  private relation: 'OR' | 'AND' = 'OR';

  constructor(private injector: Injector, private router: Router) {
  }

  public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
    this.executor = 'canActivate';
    this.activatedRoute = route;
    this.state = state;
    return this.middleware();
  }

  public canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
    this.executor = 'canActivateChild';
    this.activatedRoute = route;
    this.state = state;
    return this.middleware();
  }

  canLoad(route: Route, segments: UrlSegment[]): Promise<boolean> {
    this.executor = 'canLoad';
    this.activatedRoute = route as ActivatedRouteSnapshot;
    return this.middleware();
  }

  private middleware(): Promise<boolean> {
    let data = this.findDataWithGuards(this.activatedRoute);
    if (!data.guards || !data.guards.length) {
      return Promise.resolve(true);
    }

    if (typeof this.activatedRoute.data.guardsRelation === 'string') {
      this.relation = this.activatedRoute.data.guardsRelation.toUpperCase() === 'OR' ? 'OR' : 'AND';
    } else {
      this.relation = (data.guardsRelation === 'string' && data.guardsRelation.toUpperCase() === 'OR') ? 'OR' : 'AND';
    }

    return this.executeGuards(data.guards);
  }

  private findDataWithGuards(route: ActivatedRouteSnapshot): Data {

    if (route.data.guards) {
      return route.data;
    }

    if ((route.routeConfig.canActivateChild && ~route.routeConfig.canActivateChild.findIndex(guard => this instanceof guard))
      || (route.routeConfig.canActivate && ~route.routeConfig.canActivate.findIndex(guard => this instanceof guard))) {
      return route.data;
    }

    return this.findDataWithGuards(route.parent);
  }

  //Execute the guards sent in the route data
  private executeGuards(guards, guardIndex: number = 0): Promise<boolean> {
    return this.activateGuard(guards[guardIndex])
      .then((result) => {
        console.log(guards, guardIndex, result);
        if (this.relation === 'AND' && !result)
          return Promise.resolve(false);

        if (this.relation === 'OR' && result)
          return Promise.resolve(true);

        if (guardIndex < guards.length - 1) {
          return this.executeGuards(guards, guardIndex + 1);
        } else {
          return Promise.resolve(result);
        }
      })
      .catch(() => {
        return Promise.reject(false);
      });
  }

  private activateGuard(token): Promise<boolean> {
    let guard = this.injector.get(token);

    let result: Observable<boolean> | Promise<boolean> | boolean;
    switch (this.executor) {
      case 'canActivate':
        result = guard.canActivate(this.activatedRoute, this.state);
        break;

      case 'canActivateChild':
        result = guard.canActivateChild(this.activatedRoute, this.state);
        break;

      case 'canLoad':
        result = guard.canLoad(this.activatedRoute, this.state);
        break;

      default:
        result = guard.canActivate(this.activatedRoute, this.state);
        break;
    }

    if (typeof result === "boolean") {
      return Promise.resolve(result);
    }

    return from(result).toPromise() as Promise<boolean>;
  }
}

1, RouteのConfigからParentAuthGuardのcanActivate()が呼ばれ、現在のルートのパラメーターを受け取ります。
2, middleware()を呼び、受け取ったパラメーターからORロジックかANDロジックかを判断します。
3, AuthGuardの戻り値をもとにPromiseのBooleanを返します。

テスト用のAuthGuard例

@Injectable({
  providedIn: 'root'
})
export class TestAuthGuard implements CanActivate, CanLoad {
  constructor(private testAuthService: TestAuthService) {
  }

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    if (this.testAuthService.isTokenExist()) {
      return of(this.testAuthService.isTokenValid());
    }

    return of(false);
  }

  canLoad(route: Route, segments: UrlSegment[]): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    if (this.testAuthService.isTokenExist()) {
      return of(this.testAuthService.isTokenValid());
    }
    return of(false);
  }
}

ルートの設定例

export const routes: Routes = [
  {
    path: 'main',
    loadChildren: () => import('./main/main.module')
      .then(m => m.MainModule),
    canActivate: [ParentAuthGuard],
    data: {
      guards: [TestAuthGuard, AuthGuard],
      guardsRelation: 'OR'
    }
  }
];

以上です!

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1