From c7777c38ef0ad6a22549490d4024dcd38fa813cf Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 1 Jul 2024 12:31:37 -0400 Subject: [PATCH] Restructure the `is-paid-org` guard to be Angular 17+ compliant (#9598) * Document that `is-paid-org` guard in code * Remove unused `MessagingService` dependency * Make assertions about the way the is-paid-org guard should behave * Restructure the `is-paid-org` guard to be Angular 17+ compliant * Random commit to get the build job moving * Undo previous commit --- .../guards/is-paid-org.guard.spec.ts | 115 ++++++++++++++++++ .../organizations/guards/is-paid-org.guard.ts | 45 ++++--- .../organization-reporting-routing.module.ts | 12 +- 3 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts diff --git a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts new file mode 100644 index 0000000000..cf9a7b31dc --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts @@ -0,0 +1,115 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { RouterTestingHarness } from "@angular/router/testing"; +import { MockProxy, any, mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { DialogService } from "@bitwarden/components"; + +import { isPaidOrgGuard } from "./is-paid-org.guard"; + +@Component({ + template: "

This is the home screen!

", +}) +export class HomescreenComponent {} + +@Component({ + template: "

This component can only be accessed by a paid organization!

", +}) +export class PaidOrganizationOnlyComponent {} + +@Component({ + template: "

This is the organization upgrade screen!

", +}) +export class OrganizationUpgradeScreen {} + +const orgFactory = (props: Partial = {}) => + Object.assign( + new Organization(), + { + id: "myOrgId", + enabled: true, + type: OrganizationUserType.Admin, + }, + props, + ); + +describe("Is Paid Org Guard", () => { + let organizationService: MockProxy; + let dialogService: MockProxy; + let routerHarness: RouterTestingHarness; + + beforeEach(async () => { + organizationService = mock(); + dialogService = mock(); + + TestBed.configureTestingModule({ + providers: [ + { provide: OrganizationService, useValue: organizationService }, + { provide: DialogService, useValue: dialogService }, + provideRouter([ + { + path: "", + component: HomescreenComponent, + }, + { + path: "organizations/:organizationId/paidOrganizationsOnly", + component: PaidOrganizationOnlyComponent, + canActivate: [isPaidOrgGuard()], + }, + { + path: "organizations/:organizationId/billing/subscription", + component: OrganizationUpgradeScreen, + }, + ]), + ], + }); + + routerHarness = await RouterTestingHarness.create(); + }); + + it("redirects to `/` if the organization id provided is not found", async () => { + const org = orgFactory(); + organizationService.get.calledWith(org.id).mockResolvedValue(null); + await routerHarness.navigateByUrl(`organizations/${org.id}/paidOrganizationsOnly`); + expect(routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "").toBe( + "This is the home screen!", + ); + }); + + it("shows a dialog to users of a free organization and does not proceed with navigation", async () => { + // `useTotp` is the current indicator of a free org, it is the baseline + // feature offered above the free organization level. + const org = orgFactory({ type: OrganizationUserType.User, useTotp: false }); + organizationService.get.calledWith(org.id).mockResolvedValue(org); + await routerHarness.navigateByUrl(`organizations/${org.id}/paidOrganizationsOnly`); + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect( + routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "", + ).not.toBe("This component can only be accessed by a paid organization!"); + }); + + it("redirects users with billing access to the billing screen to upgrade", async () => { + // `useTotp` is the current indicator of a free org, it is the baseline + // feature offered above the free organization level. + const org = orgFactory({ type: OrganizationUserType.Owner, useTotp: false }); + organizationService.get.calledWith(org.id).mockResolvedValue(org); + dialogService.openSimpleDialog.calledWith(any()).mockResolvedValue(true); + await routerHarness.navigateByUrl(`organizations/${org.id}/paidOrganizationsOnly`); + expect(routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "").toBe( + "This is the organization upgrade screen!", + ); + }); + + it("proceeds with navigation if the organization in question is a paid organization", async () => { + const org = orgFactory({ useTotp: true }); + organizationService.get.calledWith(org.id).mockResolvedValue(org); + await routerHarness.navigateByUrl(`organizations/${org.id}/paidOrganizationsOnly`); + expect(routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "").toBe( + "This component can only be accessed by a paid organization!", + ); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts index aaf24e4834..e5ac9529c8 100644 --- a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts +++ b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts @@ -1,32 +1,37 @@ -import { Injectable } from "@angular/core"; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router"; +import { inject } from "@angular/core"; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from "@angular/router"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { DialogService } from "@bitwarden/components"; -@Injectable({ - providedIn: "root", -}) -export class IsPaidOrgGuard implements CanActivate { - constructor( - private router: Router, - private organizationService: OrganizationService, - private messagingService: MessagingService, - private dialogService: DialogService, - ) {} +/** + * `CanActivateFn` that checks if the organization matching the id in the URL + * parameters is paid or free. If the organization is free instructions are + * provided on how to upgrade a free organization, and the user is redirected + * if they have access to upgrade the organization. If the organization is + * paid routing proceeds." + */ +export function isPaidOrgGuard(): CanActivateFn { + return async (route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + const router = inject(Router); + const organizationService = inject(OrganizationService); + const dialogService = inject(DialogService); - async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { - const org = await this.organizationService.get(route.params.organizationId); + const org = await organizationService.get(route.params.organizationId); if (org == null) { - return this.router.createUrlTree(["/"]); + return router.createUrlTree(["/"]); } if (org.isFreeOrg) { // Users without billing permission can't access billing if (!org.canEditSubscription) { - await this.dialogService.openSimpleDialog({ + await dialogService.openSimpleDialog({ title: { key: "upgradeOrganizationCloseSecurityGaps" }, content: { key: "upgradeOrganizationCloseSecurityGapsDesc" }, acceptButtonText: { key: "ok" }, @@ -35,7 +40,7 @@ export class IsPaidOrgGuard implements CanActivate { }); return false; } else { - const upgradeConfirmed = await this.dialogService.openSimpleDialog({ + const upgradeConfirmed = await dialogService.openSimpleDialog({ title: { key: "upgradeOrganizationCloseSecurityGaps" }, content: { key: "upgradeOrganizationCloseSecurityGapsDesc" }, acceptButtonText: { key: "upgradeOrganization" }, @@ -43,7 +48,7 @@ export class IsPaidOrgGuard implements CanActivate { icon: "bwi-arrow-circle-up", }); if (upgradeConfirmed) { - await this.router.navigate(["organizations", org.id, "billing", "subscription"], { + await router.navigate(["organizations", org.id, "billing", "subscription"], { queryParams: { upgrade: true }, }); } @@ -51,5 +56,5 @@ export class IsPaidOrgGuard implements CanActivate { } return !org.isFreeOrg; - } + }; } diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts index fafce75e73..6f63e34531 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts @@ -9,7 +9,7 @@ import { InactiveTwoFactorReportComponent } from "../../../admin-console/organiz import { ReusedPasswordsReportComponent } from "../../../admin-console/organizations/tools/reused-passwords-report.component"; import { UnsecuredWebsitesReportComponent } from "../../../admin-console/organizations/tools/unsecured-websites-report.component"; import { WeakPasswordsReportComponent } from "../../../admin-console/organizations/tools/weak-passwords-report.component"; -import { IsPaidOrgGuard } from "../guards/is-paid-org.guard"; +import { isPaidOrgGuard } from "../guards/is-paid-org.guard"; import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard"; import { organizationRedirectGuard } from "../guards/org-redirect.guard"; import { EventsComponent } from "../manage/events.component"; @@ -42,7 +42,7 @@ const routes: Routes = [ data: { titleId: "exposedPasswordsReport", }, - canActivate: [IsPaidOrgGuard], + canActivate: [isPaidOrgGuard()], }, { path: "inactive-two-factor-report", @@ -50,7 +50,7 @@ const routes: Routes = [ data: { titleId: "inactive2faReport", }, - canActivate: [IsPaidOrgGuard], + canActivate: [isPaidOrgGuard()], }, { path: "reused-passwords-report", @@ -58,7 +58,7 @@ const routes: Routes = [ data: { titleId: "reusedPasswordsReport", }, - canActivate: [IsPaidOrgGuard], + canActivate: [isPaidOrgGuard()], }, { path: "unsecured-websites-report", @@ -66,7 +66,7 @@ const routes: Routes = [ data: { titleId: "unsecuredWebsitesReport", }, - canActivate: [IsPaidOrgGuard], + canActivate: [isPaidOrgGuard()], }, { path: "weak-passwords-report", @@ -74,7 +74,7 @@ const routes: Routes = [ data: { titleId: "weakPasswordsReport", }, - canActivate: [IsPaidOrgGuard], + canActivate: [isPaidOrgGuard()], }, ], },