Angular: Testing Routing Permissions

Angular: Testing Routing Permissions

When it comes to security, there is never enough testing. This blog provides you with a guide to testing your route's permissions.

I especially appreciate tests that exactly cover user requirements or acceptance criteria of my user stories.

*you can find entire code here: https://stackblitz.com/edit/angular-routing-permissions?file=src%2Fapp%2Fapp.routing.module.spec.ts

Sample scenario

Let's take this requirement as an example:

Users should have specific permission in order to visit some page.

GIVEN that user does NOT have the specific permission
WHEN the user navigates to the page
THEN a message box is shown 'You are not authorized to view this page' and the navigation is canceled.

List of pages with corresponding required permission:
1. Create Task Page (tasks/create) - Permissions.TaskCreate
2. Task Details tasks/:taskId - Permissions.TaskRead
3. Task History tasks/:taskId/history Permissions.TaskRead AND Permissions.TaskHistoryRead

This can be easily achieved using standard Angular routing with CanActivate guards and there's plenty of blogs showing how to do it, for example here.

This is an example of router configuration

export const routes: Routes = [
  {
    path: 'tasks/create',
    pathMatch: 'full',
    component: CreateTaskComponent
    canActivate: [PermissionGuard], // checks data.permission and displays the message
    data: { permission: Permissions.TaskCreate }
  },
  {
    path: 'tasks/:itemId',
    component: TaskDetailComponent,
    canDeactivate: [CanDeactivateGuard],
    canActivate: [PermissionGuard],
    data: { permission: Permissions.TaskRead },
    children: [
      {
        path: 'history',
        component:  TaskHistoryComponent,
        canActivate: [PermissionGuard],
        data: { permission: Permissions.TaskHistoryRead },
      }
    ]
  }
];

Test Cases

Let's define test cases exactly as they are described in the requirement:

/** describes routes and expected permissions. */
const testCases = [
  { path: '/tasks/create', permissions: [Permission.TaskCreate] },
  { path: '/tasks/1', permissions: [Permission.TaskRead] },
  { path: '/tasks/1/history', permissions: [Permission.TaskRead, Permission.TaskHistoryRead] },
 ]

Now, ideally we would like to have 2 tests for each test case - positive and negative:

  1. Expect the navigation to succeed when user does have the required permission (and no other permission)
  2. Expect the navigation to be canceled and message shown when user have all permissions but the required.
describe('Routing Permissions', () => { 
  for (const testCase of testCases) {
  it(`Positive test: ${testCase.path} can be navigated to, when user has ${testCase.permissions}`, () => {
  
  });
  
  it(`Negative test: ${testCase.path} shows error message when user has all but ${testCase.permissions}`, () => {
     
  });
});

What we need?

1. Create testable routes

We need to mock the actual components in the routes, because we don't really need to instantiate them when testing the router. I create copy of the existing routes and overwrite the component field:

import { routes } from './app-routing.module';

// createTestabeRoutes
const testableRoutes = mockRoutes(routes);

/** creates deep copy of the routes with fake component  */
function mockRoutes(routes_: Array<Route>): Array<Route> {
  return routes_.map(r => {
    const mock = Object.assign({}, r);
    if (mock.component) {
      mock.component = FakeComponent;
    }
    // you might need to remove also other stuf unrelated to our test cases, e.g.:
    // if (mock.canDeactivate) {
    //  mock.canDeactivate = null;
    //}
    if (mock.children) {
      mock.children = mockRoutes(mock.children);
    }
    return mock;
  });
}

@Component({selector: 'app-fake', template: ``})
class FakeComponent { }

2. Mock current user's permissions

This tightly depends on your implementation, but typically you would have something like UserService with list current user's of permissions:

export class UserServiceMock implements Partial<UserService> {
  public permissions:  Array<Permission> = [];
  public username: 'fakeuser';

  public isAuthorizedFor(permission: Permission): boolean {
    return this.permissions.indexOf(permission) >= 1;
  }
}

Putting it all together:

describe('Routing Permissions', () => {

  let location: Location;
  let router: Router;
  let dialogService: DialogService;
  let userServiceMock: UserServiceMock;

  beforeEach(() => {
    const testableRoutes = mockRoutes(routes);

    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes(testableRoutes),
      ],
      declarations: [FakeComponent],
      providers: [
        {
          provide: UserService,
          useClass: UserServiceMock,
        },
        {
          provide: DialogService,
          useValue: { showErrorAlert: () => undefined } as Partial<DialogService>
        }
      ],
    });

    router = TestBed.get(Router);
    location = TestBed.get(Location);
    dialogService = TestBed.get(DialogService);
    userServiceMock = TestBed.get(UserService);
    router.initialNavigation();
  });
  
  for (const testCase of testCases) {
  
    it(`Positive test: ${testCase.path} can be navigated to, when user has ${testCase.permissions}`, (done) => {
      userServiceMock.permissions = testCase.permissions;
      router.navigate([testCase.path]).then(navigated => {
        expect(navigated).toBe(true);
        expect(location.path()).toBe(testCase.expectedPath || testCase.path);
        done();
      }, err => fail(err));
    });
  
    it(`Negative test: ${testCase.path} shows error message when user has all but ${testCase.permissions}`, (done) => {
      userServiceMock.permissions = allPermissionsExcept(testCase.permissions);

      const showErrorSpy = spyOn(dialogService, 'showErrorAlert');

      router.navigate([testCase.path]).then(navigated => {
        const p = location.path();
        expect(navigated).toBe(false);
        expect(showErrorSpy).toHaveBeenCalled();
        done();
      }, err => fail(err));
    });
});

/** returns list of all possible Permission except ignoredPermissions */
function allPermissionsExcept(ignoredPermissions: Array<Permission>): Array<Permission> {
  return Object.values(Permission).filter(p => ignoredPermissions.indexOf(p) === -1);
}

Summary

We have implemented tests that exactly map to our original requirements for routing permissions. We have covered each route with positive and negative test. If we look back at our testCases array, it perfectly serves as a documentation of our current route's permissions implementation.