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
This commit is contained in:
Addison Beck 2024-07-01 12:31:37 -04:00 committed by GitHub
parent 432a4ddd17
commit c7777c38ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 146 additions and 26 deletions

View File

@ -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: "<h1>This is the home screen!</h1>",
})
export class HomescreenComponent {}
@Component({
template: "<h1>This component can only be accessed by a paid organization!</h1>",
})
export class PaidOrganizationOnlyComponent {}
@Component({
template: "<h1>This is the organization upgrade screen!</h1>",
})
export class OrganizationUpgradeScreen {}
const orgFactory = (props: Partial<Organization> = {}) =>
Object.assign(
new Organization(),
{
id: "myOrgId",
enabled: true,
type: OrganizationUserType.Admin,
},
props,
);
describe("Is Paid Org Guard", () => {
let organizationService: MockProxy<OrganizationService>;
let dialogService: MockProxy<DialogService>;
let routerHarness: RouterTestingHarness;
beforeEach(async () => {
organizationService = mock<OrganizationService>();
dialogService = mock<DialogService>();
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!",
);
});
});

View File

@ -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;
}
};
}

View File

@ -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()],
},
],
},