mirror of
https://github.com/bitwarden/browser
synced 2025-12-21 10:43:35 +00:00
PM-15070 Star critical apps (#12109)
Ability to star a record when flagged as critical. This is still behind a feature flag
This commit is contained in:
@@ -30,6 +30,10 @@ export type ApplicationHealthReportDetail = {
|
||||
atRiskMemberDetails: MemberDetailsFlat[];
|
||||
};
|
||||
|
||||
export type ApplicationHealthReportDetailWithCriticalFlag = ApplicationHealthReportDetail & {
|
||||
isMarkedAsCritical: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Breaks the cipher health info out by uri and passes
|
||||
* along the password health and member info
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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 {
|
||||
PasswordHealthReportApplicationId,
|
||||
PasswordHealthReportApplicationsRequest,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "./critical-apps.service";
|
||||
|
||||
describe("CriticalAppsApiService", () => {
|
||||
let service: CriticalAppsApiService;
|
||||
const apiService = mock<ApiService>();
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CriticalAppsApiService(apiService);
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should call apiService.send with correct parameters for SaveCriticalApps", (done) => {
|
||||
const requests: PasswordHealthReportApplicationsRequest[] = [
|
||||
{ organizationId: "org1" as OrganizationId, url: "test one" },
|
||||
{ organizationId: "org1" as OrganizationId, url: "test two" },
|
||||
];
|
||||
const response: PasswordHealthReportApplicationsResponse[] = [
|
||||
{
|
||||
id: "1" as PasswordHealthReportApplicationId,
|
||||
organizationId: "org1" as OrganizationId,
|
||||
uri: "test one",
|
||||
},
|
||||
{
|
||||
id: "2" as PasswordHealthReportApplicationId,
|
||||
organizationId: "org1" as OrganizationId,
|
||||
uri: "test two",
|
||||
},
|
||||
];
|
||||
|
||||
apiService.send.mockReturnValue(Promise.resolve(response));
|
||||
|
||||
service.saveCriticalApps(requests).subscribe((result) => {
|
||||
expect(result).toEqual(response);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/reports/password-health-report-applications/",
|
||||
requests,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call apiService.send with correct parameters for GetCriticalApps", (done) => {
|
||||
const orgId: OrganizationId = "org1" as OrganizationId;
|
||||
const response: PasswordHealthReportApplicationsResponse[] = [
|
||||
{ id: "1" as PasswordHealthReportApplicationId, organizationId: orgId, uri: "test one" },
|
||||
{ id: "2" as PasswordHealthReportApplicationId, organizationId: orgId, uri: "test two" },
|
||||
];
|
||||
|
||||
apiService.send.mockReturnValue(Promise.resolve(response));
|
||||
|
||||
service.getCriticalApps(orgId).subscribe((result) => {
|
||||
expect(result).toEqual(response);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/reports/password-health-report-applications/${orgId.toString()}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { from, Observable } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
PasswordHealthReportApplicationsRequest,
|
||||
PasswordHealthReportApplicationsResponse,
|
||||
} from "./critical-apps.service";
|
||||
|
||||
export class CriticalAppsApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
saveCriticalApps(
|
||||
requests: PasswordHealthReportApplicationsRequest[],
|
||||
): Observable<PasswordHealthReportApplicationsResponse[]> {
|
||||
const dbResponse = this.apiService.send(
|
||||
"POST",
|
||||
"/reports/password-health-report-applications/",
|
||||
requests,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return from(dbResponse as Promise<PasswordHealthReportApplicationsResponse[]>);
|
||||
}
|
||||
|
||||
getCriticalApps(orgId: OrganizationId): Observable<PasswordHealthReportApplicationsResponse[]> {
|
||||
const dbResponse = this.apiService.send(
|
||||
"GET",
|
||||
`/reports/password-health-report-applications/${orgId.toString()}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return from(dbResponse as Promise<PasswordHealthReportApplicationsResponse[]>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { fakeAsync, flush } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
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";
|
||||
|
||||
describe("CriticalAppsService", () => {
|
||||
let service: CriticalAppsService;
|
||||
const keyService = mock<KeyService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const criticalAppsApiService = mock<CriticalAppsApiService>({
|
||||
saveCriticalApps: jest.fn(),
|
||||
getCriticalApps: jest.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CriticalAppsService(keyService, encryptService, criticalAppsApiService);
|
||||
|
||||
// reset mocks
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should set critical apps", async () => {
|
||||
// arrange
|
||||
const criticalApps = ["https://example.com", "https://example.org"];
|
||||
|
||||
const request = [
|
||||
{ organizationId: "org1", url: "encryptedUrlName" },
|
||||
{ organizationId: "org1", url: "encryptedUrlName" },
|
||||
] as PasswordHealthReportApplicationsRequest[];
|
||||
|
||||
const response = [
|
||||
{ id: "id1", organizationId: "org1", uri: "https://example.com" },
|
||||
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
|
||||
] as PasswordHealthReportApplicationsResponse[];
|
||||
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("encryptedUrlName"));
|
||||
criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response));
|
||||
|
||||
// act
|
||||
await service.setCriticalApps("org1", criticalApps);
|
||||
|
||||
// expectations
|
||||
expect(keyService.getOrgKey).toHaveBeenCalledWith("org1");
|
||||
expect(encryptService.encrypt).toHaveBeenCalledTimes(2);
|
||||
expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it("should exclude records that already exist", async () => {
|
||||
// arrange
|
||||
// one record already exists
|
||||
service.setAppsInListForOrg([
|
||||
{
|
||||
id: randomUUID() as PasswordHealthReportApplicationId,
|
||||
organizationId: "org1" as OrganizationId,
|
||||
uri: "https://example.com",
|
||||
},
|
||||
]);
|
||||
|
||||
// two records are selected - one already in the database
|
||||
const selectedUrls = ["https://example.com", "https://example.org"];
|
||||
|
||||
// expect only one record to be sent to the server
|
||||
const request = [
|
||||
{ organizationId: "org1", url: "encryptedUrlName" },
|
||||
] as PasswordHealthReportApplicationsRequest[];
|
||||
|
||||
// mocked response
|
||||
const response = [
|
||||
{ id: "id1", organizationId: "org1", uri: "test" },
|
||||
] as PasswordHealthReportApplicationsResponse[];
|
||||
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("encryptedUrlName"));
|
||||
criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response));
|
||||
|
||||
// act
|
||||
await service.setCriticalApps("org1", selectedUrls);
|
||||
|
||||
// expectations
|
||||
expect(keyService.getOrgKey).toHaveBeenCalledWith("org1");
|
||||
expect(encryptService.encrypt).toHaveBeenCalledTimes(1);
|
||||
expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it("should get critical apps", fakeAsync(() => {
|
||||
const orgId = "org1" as OrganizationId;
|
||||
const response = [
|
||||
{ id: "id1", organizationId: "org1", uri: "https://example.com" },
|
||||
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
|
||||
] as PasswordHealthReportApplicationsResponse[];
|
||||
|
||||
encryptService.decryptToUtf8.mockResolvedValue("https://example.com");
|
||||
criticalAppsApiService.getCriticalApps.mockReturnValue(of(response));
|
||||
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
|
||||
keyService.getOrgKey.mockResolvedValue(mockOrgKey);
|
||||
|
||||
service.setOrganizationId(orgId as OrganizationId);
|
||||
flush();
|
||||
|
||||
expect(keyService.getOrgKey).toHaveBeenCalledWith(orgId.toString());
|
||||
expect(encryptService.decryptToUtf8).toHaveBeenCalledTimes(2);
|
||||
expect(criticalAppsApiService.getCriticalApps).toHaveBeenCalledWith(orgId);
|
||||
}));
|
||||
|
||||
it("should get by org id", () => {
|
||||
const orgId = "org1" as OrganizationId;
|
||||
const response = [
|
||||
{ id: "id1", organizationId: "org1", uri: "https://example.com" },
|
||||
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
|
||||
{ id: "id3", organizationId: "org2", uri: "https://example.org" },
|
||||
{ id: "id4", organizationId: "org2", uri: "https://example.org" },
|
||||
] as PasswordHealthReportApplicationsResponse[];
|
||||
|
||||
service.setAppsInListForOrg(response);
|
||||
|
||||
service.getAppsListForOrg(orgId as OrganizationId).subscribe((res) => {
|
||||
expect(res).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
BehaviorSubject,
|
||||
first,
|
||||
firstValueFrom,
|
||||
forkJoin,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
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 { CriticalAppsApiService } from "./critical-apps-api.service";
|
||||
|
||||
/* Retrieves and decrypts critical apps for a given organization
|
||||
* Encrypts and saves data for a given organization
|
||||
*/
|
||||
export class CriticalAppsService {
|
||||
private orgId = new BehaviorSubject<OrganizationId | null>(null);
|
||||
private criticalAppsList = new BehaviorSubject<PasswordHealthReportApplicationsResponse[]>([]);
|
||||
private teardown = new Subject<void>();
|
||||
|
||||
private fetchOrg$ = this.orgId
|
||||
.pipe(
|
||||
switchMap((orgId) => this.retrieveCriticalApps(orgId)),
|
||||
takeUntil(this.teardown),
|
||||
)
|
||||
.subscribe((apps) => this.criticalAppsList.next(apps));
|
||||
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private encryptService: EncryptService,
|
||||
private criticalAppsApiService: CriticalAppsApiService,
|
||||
) {}
|
||||
|
||||
// Get a list of critical apps for a given organization
|
||||
getAppsListForOrg(orgId: string): Observable<PasswordHealthReportApplicationsResponse[]> {
|
||||
return this.criticalAppsList
|
||||
.asObservable()
|
||||
.pipe(map((apps) => apps.filter((app) => app.organizationId === orgId)));
|
||||
}
|
||||
|
||||
// Reset the critical apps list
|
||||
setAppsInListForOrg(apps: PasswordHealthReportApplicationsResponse[]) {
|
||||
this.criticalAppsList.next(apps);
|
||||
}
|
||||
|
||||
// Save the selected critical apps for a given organization
|
||||
async setCriticalApps(orgId: string, selectedUrls: string[]) {
|
||||
const key = await this.keyService.getOrgKey(orgId);
|
||||
|
||||
// only save records that are not already in the database
|
||||
const newEntries = await this.filterNewEntries(orgId as OrganizationId, selectedUrls);
|
||||
const criticalAppsRequests = await this.encryptNewEntries(
|
||||
orgId as OrganizationId,
|
||||
key,
|
||||
newEntries,
|
||||
);
|
||||
|
||||
const dbResponse = await firstValueFrom(
|
||||
this.criticalAppsApiService.saveCriticalApps(criticalAppsRequests),
|
||||
);
|
||||
|
||||
// add the new entries to the criticalAppsList
|
||||
const updatedList = [...this.criticalAppsList.value];
|
||||
for (const responseItem of dbResponse) {
|
||||
const decryptedUrl = await this.encryptService.decryptToUtf8(
|
||||
new EncString(responseItem.uri),
|
||||
key,
|
||||
);
|
||||
if (!updatedList.some((f) => f.uri === decryptedUrl)) {
|
||||
updatedList.push({
|
||||
id: responseItem.id,
|
||||
organizationId: responseItem.organizationId,
|
||||
uri: decryptedUrl,
|
||||
} as PasswordHealthReportApplicationsResponse);
|
||||
}
|
||||
}
|
||||
this.criticalAppsList.next(updatedList);
|
||||
}
|
||||
|
||||
// Get the critical apps for a given organization
|
||||
setOrganizationId(orgId: OrganizationId) {
|
||||
this.orgId.next(orgId);
|
||||
}
|
||||
|
||||
private retrieveCriticalApps(
|
||||
orgId: OrganizationId | null,
|
||||
): Observable<PasswordHealthReportApplicationsResponse[]> {
|
||||
if (orgId === null) {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
const result$ = zip(
|
||||
this.criticalAppsApiService.getCriticalApps(orgId),
|
||||
from(this.keyService.getOrgKey(orgId)),
|
||||
).pipe(
|
||||
switchMap(([response, key]) => {
|
||||
const results = response.map(async (r: PasswordHealthReportApplicationsResponse) => {
|
||||
const encrypted = new EncString(r.uri);
|
||||
const uri = await this.encryptService.decryptToUtf8(encrypted, key);
|
||||
return { id: r.id, organizationId: r.organizationId, uri: uri };
|
||||
});
|
||||
return forkJoin(results);
|
||||
}),
|
||||
first(),
|
||||
);
|
||||
|
||||
return result$ as Observable<PasswordHealthReportApplicationsResponse[]>;
|
||||
}
|
||||
|
||||
private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise<string[]> {
|
||||
return await firstValueFrom(this.criticalAppsList).then((criticalApps) => {
|
||||
const criticalAppsUri = criticalApps
|
||||
.filter((f) => f.organizationId === orgId)
|
||||
.map((f) => f.uri);
|
||||
return selectedUrls.filter((url) => !criticalAppsUri.includes(url));
|
||||
});
|
||||
}
|
||||
|
||||
private async encryptNewEntries(
|
||||
orgId: OrganizationId,
|
||||
key: OrgKey,
|
||||
newEntries: string[],
|
||||
): Promise<PasswordHealthReportApplicationsRequest[]> {
|
||||
const criticalAppsPromises = newEntries.map(async (url) => {
|
||||
const encryptedUrlName = await this.encryptService.encrypt(url, key);
|
||||
return {
|
||||
organizationId: orgId,
|
||||
url: encryptedUrlName?.encryptedString?.toString() ?? "",
|
||||
} as PasswordHealthReportApplicationsRequest;
|
||||
});
|
||||
|
||||
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">;
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./member-cipher-details-api.service";
|
||||
export * from "./password-health.service";
|
||||
export * from "./critical-apps.service";
|
||||
export * from "./critical-apps-api.service";
|
||||
export * from "./risk-insights-report.service";
|
||||
export * from "./risk-insights-data.service";
|
||||
|
||||
Reference in New Issue
Block a user