Skip to content

Commit

Permalink
PM-15090 - unmark-critical-app (#13015)
Browse files Browse the repository at this point in the history
  • Loading branch information
voommen-livefront authored Jan 23, 2025
1 parent 8a2aa1e commit 9d83484
Show file tree
Hide file tree
Showing 9 changed files with 203 additions and 24 deletions.
6 changes: 6 additions & 0 deletions apps/web/src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@
"totalApplications": {
"message": "Total applications"
},
"unmarkAsCriticalApp": {
"message": "Unmark as critical app"
},
"criticalApplicationSuccessfullyUnmarked": {
"message": "Critical application successfully unmarked"
},
"whatTypeOfItem": {
"message": "What type of item is this?"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore

import { Opaque } from "type-fest";

import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components";

Expand Down Expand Up @@ -113,3 +116,31 @@ export type AtRiskApplicationDetail = {
applicationName: string;
atRiskPasswordCount: number;
};

/**
* Request to drop a password health report application
* Model is expected by the API endpoint
*/
export interface PasswordHealthReportApplicationDropRequest {
organizationId: OrganizationId;
passwordHealthReportApplicationIds: string[];
}

/**
* Response from the API after marking an app as critical
*/
export interface PasswordHealthReportApplicationsResponse {
id: PasswordHealthReportApplicationId;
organizationId: OrganizationId;
uri: string;
}
/*
* Request to save a password health report application
* Model is expected by the API endpoint
*/
export interface PasswordHealthReportApplicationsRequest {
organizationId: OrganizationId;
url: string;
}

export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationId } from "@bitwarden/common/types/guid";

import { CriticalAppsApiService } from "./critical-apps-api.service";
import {
PasswordHealthReportApplicationDropRequest,
PasswordHealthReportApplicationId,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "./critical-apps.service";
} from "../models/password-health";

import { CriticalAppsApiService } from "./critical-apps-api.service";

describe("CriticalAppsApiService", () => {
let service: CriticalAppsApiService;
Expand Down Expand Up @@ -76,4 +78,24 @@ describe("CriticalAppsApiService", () => {
done();
});
});

it("should call apiService.send with correct parameters for DropCriticalApp", (done) => {
const request: PasswordHealthReportApplicationDropRequest = {
organizationId: "org1" as OrganizationId,
passwordHealthReportApplicationIds: ["123"],
};

apiService.send.mockReturnValue(Promise.resolve());

service.dropCriticalApp(request).subscribe(() => {
expect(apiService.send).toHaveBeenCalledWith(
"DELETE",
"/reports/password-health-report-application/",
request,
true,
true,
);
done();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationId } from "@bitwarden/common/types/guid";

import {
PasswordHealthReportApplicationDropRequest,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "./critical-apps.service";
} from "../models/password-health";

export class CriticalAppsApiService {
constructor(private apiService: ApiService) {}
Expand Down Expand Up @@ -36,4 +37,16 @@ export class CriticalAppsApiService {

return from(dbResponse as Promise<PasswordHealthReportApplicationsResponse[]>);
}

dropCriticalApp(request: PasswordHealthReportApplicationDropRequest): Observable<void> {
const dbResponse = this.apiService.send(
"DELETE",
"/reports/password-health-report-application/",
request,
true,
true,
);

return from(dbResponse as Promise<void>);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";

import { CriticalAppsApiService } from "./critical-apps-api.service";
import {
CriticalAppsService,
PasswordHealthReportApplicationId,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "./critical-apps.service";
} from "../models/password-health";

import { CriticalAppsApiService } from "./critical-apps-api.service";
import { CriticalAppsService } from "./critical-apps.service";

describe("CriticalAppsService", () => {
let service: CriticalAppsService;
Expand Down Expand Up @@ -139,4 +140,54 @@ describe("CriticalAppsService", () => {
expect(res).toHaveLength(2);
});
});

it("should drop a critical app", async () => {
// arrange
const orgId = "org1" as OrganizationId;
const selectedUrl = "https://example.com";

const initialList = [
{ id: "id1", organizationId: "org1", uri: "https://example.com" },
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
] as PasswordHealthReportApplicationsResponse[];

service.setAppsInListForOrg(initialList);

// act
await service.dropCriticalApp(orgId, selectedUrl);

// expectations
expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({
organizationId: orgId,
passwordHealthReportApplicationIds: ["id1"],
});
expect(service.getAppsListForOrg(orgId)).toBeTruthy();
service.getAppsListForOrg(orgId).subscribe((res) => {
expect(res).toHaveLength(1);
expect(res[0].uri).toBe("https://example.org");
});
});

it("should not drop a critical app if it does not exist", async () => {
// arrange
const orgId = "org1" as OrganizationId;
const selectedUrl = "https://nonexistent.com";

const initialList = [
{ id: "id1", organizationId: "org1", uri: "https://example.com" },
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
] as PasswordHealthReportApplicationsResponse[];

service.setAppsInListForOrg(initialList);

// act
await service.dropCriticalApp(orgId, selectedUrl);

// expectations
expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled();
expect(service.getAppsListForOrg(orgId)).toBeTruthy();
service.getAppsListForOrg(orgId).subscribe((res) => {
expect(res).toHaveLength(2);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ import {
takeUntil,
zip,
} from "rxjs";
import { Opaque } from "type-fest";

import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";

import {
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "../models/password-health";

import { CriticalAppsApiService } from "./critical-apps-api.service";

/* Retrieves and decrypts critical apps for a given organization
Expand Down Expand Up @@ -94,6 +98,25 @@ export class CriticalAppsService {
this.orgId.next(orgId);
}

// Drop a critical app for a given organization
// Only one app may be dropped at a time
async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) {
const app = this.criticalAppsList.value.find(
(f) => f.organizationId === orgId && f.uri === selectedUrl,
);

if (!app) {
return;
}

await this.criticalAppsApiService.dropCriticalApp({
organizationId: app.organizationId,
passwordHealthReportApplicationIds: [app.id],
});

this.criticalAppsList.next(this.criticalAppsList.value.filter((f) => f.uri !== selectedUrl));
}

private retrieveCriticalApps(
orgId: OrganizationId | null,
): Observable<PasswordHealthReportApplicationsResponse[]> {
Expand Down Expand Up @@ -144,16 +167,3 @@ export class CriticalAppsService {
return await Promise.all(criticalAppsPromises);
}
}

export interface PasswordHealthReportApplicationsRequest {
organizationId: OrganizationId;
url: string;
}

export interface PasswordHealthReportApplicationsResponse {
id: PasswordHealthReportApplicationId;
organizationId: OrganizationId;
uri: string;
}

export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ <h2 bitTypography="h2">{{ "criticalApplications" | i18n }}</h2>
<td bitCell data-testid="total-membership">
{{ r.memberCount }}
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>

<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="unmarkAsCriticalApp(r.applicationName)">
<i aria-hidden="true" class="bwi bwi-star-f"></i> {{ "unmarkAsCriticalApp" | i18n }}
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ import {
ApplicationHealthReportDetailWithCriticalFlag,
ApplicationHealthReportSummary,
} from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
DialogService,
Icons,
NoItemsModule,
SearchModule,
TableDataSource,
ToastService,
} from "@bitwarden/components";
import { CardComponent } from "@bitwarden/tools-card";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
Expand All @@ -37,6 +40,7 @@ import { RiskInsightsTabType } from "./risk-insights.component";
selector: "tools-critical-applications",
templateUrl: "./critical-applications.component.html",
imports: [CardComponent, HeaderModule, SearchModule, NoItemsModule, PipesModule, SharedModule],
providers: [],
})
export class CriticalApplicationsComponent implements OnInit {
protected dataSource = new TableDataSource<ApplicationHealthReportDetailWithCriticalFlag>();
Expand Down Expand Up @@ -80,13 +84,38 @@ export class CriticalApplicationsComponent implements OnInit {
});
};

unmarkAsCriticalApp = async (hostname: string) => {
try {
await this.criticalAppsService.dropCriticalApp(
this.organizationId as OrganizationId,
hostname,
);
} catch {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
variant: "error",
title: this.i18nService.t("error"),
});
return;
}

this.toastService.showToast({
message: this.i18nService.t("criticalApplicationSuccessfullyUnmarked"),
variant: "success",
title: this.i18nService.t("success"),
});
this.dataSource.data = this.dataSource.data.filter((app) => app.applicationName !== hostname);
};

constructor(
protected activatedRoute: ActivatedRoute,
protected router: Router,
protected toastService: ToastService,
protected dataService: RiskInsightsDataService,
protected criticalAppsService: CriticalAppsService,
protected reportService: RiskInsightsReportService,
protected dialogService: DialogService,
protected i18nService: I18nService,
) {
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { Observable, EMPTY } from "rxjs";
import { EMPTY, Observable } from "rxjs";
import { map, switchMap } from "rxjs/operators";

import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
RiskInsightsDataService,
CriticalAppsService,
PasswordHealthReportApplicationsResponse,
RiskInsightsDataService,
} from "@bitwarden/bit-common/tools/reports/risk-insights";
import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health";
import {
ApplicationHealthReportDetail,
PasswordHealthReportApplicationsResponse,
} from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health";
// eslint-disable-next-line no-restricted-imports -- used for dependency injection
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
Expand Down

0 comments on commit 9d83484

Please sign in to comment.