mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +00:00
[PM-17563] Security task background synchronization (#14086)
* [PM-17563] Implement listenForTaskNotifications in default-task.service.ts * [PM-17563] Update syncService to include userId in syncCompleted message payload * [PM-17563] Update default-task.service to react to both pending task notifications and completed syncs * [PM-17563] Add unit tests around task notification listening * [PM-17563] Only check for at risk password tasks if tasks are enabled * [PM-17563] Make userId required even if undefined * [PM-17563] Use abstract TaskService instead of default implementation in MainBackground * [PM-17563] Cleanup userId filtering
This commit is contained in:
@@ -200,6 +200,7 @@ import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder
|
|||||||
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
||||||
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
||||||
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
|
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
|
||||||
|
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
|
||||||
import {
|
import {
|
||||||
legacyPasswordGenerationServiceFactory,
|
legacyPasswordGenerationServiceFactory,
|
||||||
legacyUsernameGenerationServiceFactory,
|
legacyUsernameGenerationServiceFactory,
|
||||||
@@ -400,6 +401,7 @@ export default class MainBackground {
|
|||||||
sdkLoadService: SdkLoadService;
|
sdkLoadService: SdkLoadService;
|
||||||
cipherAuthorizationService: CipherAuthorizationService;
|
cipherAuthorizationService: CipherAuthorizationService;
|
||||||
inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
|
inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
|
||||||
|
taskService: TaskService;
|
||||||
|
|
||||||
onUpdatedRan: boolean;
|
onUpdatedRan: boolean;
|
||||||
onReplacedRan: boolean;
|
onReplacedRan: boolean;
|
||||||
@@ -1296,6 +1298,16 @@ export default class MainBackground {
|
|||||||
this.configService,
|
this.configService,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.taskService = new DefaultTaskService(
|
||||||
|
this.stateProvider,
|
||||||
|
this.apiService,
|
||||||
|
this.organizationService,
|
||||||
|
this.configService,
|
||||||
|
this.authService,
|
||||||
|
this.notificationsService,
|
||||||
|
messageListener,
|
||||||
|
);
|
||||||
|
|
||||||
this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
|
this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1377,6 +1389,11 @@ export default class MainBackground {
|
|||||||
await this.fullSync(false);
|
await this.fullSync(false);
|
||||||
this.backgroundSyncService.init();
|
this.backgroundSyncService.init();
|
||||||
this.notificationsService.startListening();
|
this.notificationsService.startListening();
|
||||||
|
|
||||||
|
if (await this.configService.getFeatureFlag(FeatureFlag.SecurityTasks)) {
|
||||||
|
this.taskService.listenForTaskNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, inject } from "@angular/core";
|
import { Component, inject } from "@angular/core";
|
||||||
import { RouterModule } from "@angular/router";
|
import { RouterModule } from "@angular/router";
|
||||||
import { map, switchMap } from "rxjs";
|
import { map, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
|
||||||
import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components";
|
import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components";
|
||||||
import { I18nPipe } from "@bitwarden/ui-common";
|
import { I18nPipe } from "@bitwarden/ui-common";
|
||||||
|
|
||||||
// TODO: This component will need to be reworked to use the new EndUserNotificationService in PM-10609
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "vault-at-risk-password-callout",
|
selector: "vault-at-risk-password-callout",
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -19,14 +17,23 @@ import { I18nPipe } from "@bitwarden/ui-common";
|
|||||||
})
|
})
|
||||||
export class AtRiskPasswordCalloutComponent {
|
export class AtRiskPasswordCalloutComponent {
|
||||||
private taskService = inject(TaskService);
|
private taskService = inject(TaskService);
|
||||||
private activeAccount$ = inject(AccountService).activeAccount$.pipe(filterOutNullish());
|
private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId);
|
||||||
|
|
||||||
protected pendingTasks$ = this.activeAccount$.pipe(
|
protected pendingTasks$ = this.activeAccount$.pipe(
|
||||||
switchMap((user) =>
|
switchMap((userId) =>
|
||||||
this.taskService
|
this.taskService.tasksEnabled$(userId).pipe(
|
||||||
.pendingTasks$(user.id)
|
switchMap((enabled) => {
|
||||||
|
if (!enabled) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
return this.taskService
|
||||||
|
.pendingTasks$(userId)
|
||||||
.pipe(
|
.pipe(
|
||||||
map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)),
|
map((tasks) =>
|
||||||
|
tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1475,7 +1475,15 @@ const safeProviders: SafeProvider[] = [
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
provide: TaskService,
|
provide: TaskService,
|
||||||
useClass: DefaultTaskService,
|
useClass: DefaultTaskService,
|
||||||
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
|
deps: [
|
||||||
|
StateProvider,
|
||||||
|
ApiServiceAbstraction,
|
||||||
|
OrganizationServiceAbstraction,
|
||||||
|
ConfigService,
|
||||||
|
AuthServiceAbstraction,
|
||||||
|
NotificationsService,
|
||||||
|
MessageListener,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: EndUserNotificationService,
|
provide: EndUserNotificationService,
|
||||||
|
|||||||
@@ -26,4 +26,6 @@ export enum NotificationType {
|
|||||||
SyncOrganizationCollectionSettingChanged = 19,
|
SyncOrganizationCollectionSettingChanged = 19,
|
||||||
Notification = 20,
|
Notification = 20,
|
||||||
NotificationStatus = 21,
|
NotificationStatus = 21,
|
||||||
|
|
||||||
|
PendingSecurityTasks = 22,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,14 +105,14 @@ export abstract class CoreSyncService implements SyncService {
|
|||||||
if (remoteFolder != null) {
|
if (remoteFolder != null) {
|
||||||
await this.folderService.upsert(new FolderData(remoteFolder), userId);
|
await this.folderService.upsert(new FolderData(remoteFolder), userId);
|
||||||
this.messageSender.send("syncedUpsertedFolder", { folderId: notification.id });
|
this.messageSender.send("syncedUpsertedFolder", { folderId: notification.id });
|
||||||
return this.syncCompleted(true);
|
return this.syncCompleted(true, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logService.error(e);
|
this.logService.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.syncCompleted(false);
|
return this.syncCompleted(false, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncDeleteFolder(notification: SyncFolderNotification, userId: UserId): Promise<boolean> {
|
async syncDeleteFolder(notification: SyncFolderNotification, userId: UserId): Promise<boolean> {
|
||||||
@@ -123,10 +123,10 @@ export abstract class CoreSyncService implements SyncService {
|
|||||||
if (authStatus >= AuthenticationStatus.Locked) {
|
if (authStatus >= AuthenticationStatus.Locked) {
|
||||||
await this.folderService.delete(notification.id, userId);
|
await this.folderService.delete(notification.id, userId);
|
||||||
this.messageSender.send("syncedDeletedFolder", { folderId: notification.id });
|
this.messageSender.send("syncedDeletedFolder", { folderId: notification.id });
|
||||||
this.syncCompleted(true);
|
this.syncCompleted(true, userId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return this.syncCompleted(false);
|
return this.syncCompleted(false, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncUpsertCipher(
|
async syncUpsertCipher(
|
||||||
@@ -183,18 +183,18 @@ export abstract class CoreSyncService implements SyncService {
|
|||||||
if (remoteCipher != null) {
|
if (remoteCipher != null) {
|
||||||
await this.cipherService.upsert(new CipherData(remoteCipher));
|
await this.cipherService.upsert(new CipherData(remoteCipher));
|
||||||
this.messageSender.send("syncedUpsertedCipher", { cipherId: notification.id });
|
this.messageSender.send("syncedUpsertedCipher", { cipherId: notification.id });
|
||||||
return this.syncCompleted(true);
|
return this.syncCompleted(true, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e != null && e.statusCode === 404 && isEdit) {
|
if (e != null && e.statusCode === 404 && isEdit) {
|
||||||
await this.cipherService.delete(notification.id, userId);
|
await this.cipherService.delete(notification.id, userId);
|
||||||
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
|
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
|
||||||
return this.syncCompleted(true);
|
return this.syncCompleted(true, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.syncCompleted(false);
|
return this.syncCompleted(false, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncDeleteCipher(notification: SyncCipherNotification, userId: UserId): Promise<boolean> {
|
async syncDeleteCipher(notification: SyncCipherNotification, userId: UserId): Promise<boolean> {
|
||||||
@@ -204,9 +204,9 @@ export abstract class CoreSyncService implements SyncService {
|
|||||||
if (authStatus >= AuthenticationStatus.Locked) {
|
if (authStatus >= AuthenticationStatus.Locked) {
|
||||||
await this.cipherService.delete(notification.id, userId);
|
await this.cipherService.delete(notification.id, userId);
|
||||||
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
|
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
|
||||||
return this.syncCompleted(true);
|
return this.syncCompleted(true, userId);
|
||||||
}
|
}
|
||||||
return this.syncCompleted(false);
|
return this.syncCompleted(false, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean> {
|
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean> {
|
||||||
@@ -234,14 +234,15 @@ export abstract class CoreSyncService implements SyncService {
|
|||||||
if (remoteSend != null) {
|
if (remoteSend != null) {
|
||||||
await this.sendService.upsert(new SendData(remoteSend));
|
await this.sendService.upsert(new SendData(remoteSend));
|
||||||
this.messageSender.send("syncedUpsertedSend", { sendId: notification.id });
|
this.messageSender.send("syncedUpsertedSend", { sendId: notification.id });
|
||||||
return this.syncCompleted(true);
|
return this.syncCompleted(true, activeUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logService.error(e);
|
this.logService.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.syncCompleted(false);
|
// TODO: Update syncCompleted userId when send service allows modification of non-active users
|
||||||
|
return this.syncCompleted(false, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncDeleteSend(notification: SyncSendNotification): Promise<boolean> {
|
async syncDeleteSend(notification: SyncSendNotification): Promise<boolean> {
|
||||||
@@ -249,10 +250,11 @@ export abstract class CoreSyncService implements SyncService {
|
|||||||
if (await this.stateService.getIsAuthenticated()) {
|
if (await this.stateService.getIsAuthenticated()) {
|
||||||
await this.sendService.delete(notification.id);
|
await this.sendService.delete(notification.id);
|
||||||
this.messageSender.send("syncedDeletedSend", { sendId: notification.id });
|
this.messageSender.send("syncedDeletedSend", { sendId: notification.id });
|
||||||
this.syncCompleted(true);
|
// TODO: Update syncCompleted userId when send service allows modification of non-active users
|
||||||
|
this.syncCompleted(true, undefined);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return this.syncCompleted(false);
|
return this.syncCompleted(false, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -262,9 +264,9 @@ export abstract class CoreSyncService implements SyncService {
|
|||||||
this.messageSender.send("syncStarted");
|
this.messageSender.send("syncStarted");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected syncCompleted(successfully: boolean): boolean {
|
protected syncCompleted(successfully: boolean, userId: UserId | undefined): boolean {
|
||||||
this.syncInProgress = false;
|
this.syncInProgress = false;
|
||||||
this.messageSender.send("syncCompleted", { successfully: successfully });
|
this.messageSender.send("syncCompleted", { successfully: successfully, userId });
|
||||||
return successfully;
|
return successfully;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { firstValueFrom, map } from "rxjs";
|
import { firstValueFrom, map } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CollectionService,
|
|
||||||
CollectionData,
|
CollectionData,
|
||||||
CollectionDetailsResponse,
|
CollectionDetailsResponse,
|
||||||
|
CollectionService,
|
||||||
} from "@bitwarden/admin-console/common";
|
} from "@bitwarden/admin-console/common";
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
import { KeyService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ export class DefaultSyncService extends CoreSyncService {
|
|||||||
this.syncStarted();
|
this.syncStarted();
|
||||||
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
|
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
|
||||||
if (authStatus === AuthenticationStatus.LoggedOut) {
|
if (authStatus === AuthenticationStatus.LoggedOut) {
|
||||||
return this.syncCompleted(false);
|
return this.syncCompleted(false, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -116,14 +116,14 @@ export class DefaultSyncService extends CoreSyncService {
|
|||||||
needsSync = await this.needsSyncing(forceSync);
|
needsSync = await this.needsSyncing(forceSync);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (allowThrowOnError) {
|
if (allowThrowOnError) {
|
||||||
this.syncCompleted(false);
|
this.syncCompleted(false, userId);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!needsSync) {
|
if (!needsSync) {
|
||||||
await this.setLastSync(now, userId);
|
await this.setLastSync(now, userId);
|
||||||
return this.syncCompleted(false);
|
return this.syncCompleted(false, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -139,13 +139,13 @@ export class DefaultSyncService extends CoreSyncService {
|
|||||||
await this.syncPolicies(response.policies, response.profile.id);
|
await this.syncPolicies(response.policies, response.profile.id);
|
||||||
|
|
||||||
await this.setLastSync(now, userId);
|
await this.setLastSync(now, userId);
|
||||||
return this.syncCompleted(true);
|
return this.syncCompleted(true, userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (allowThrowOnError) {
|
if (allowThrowOnError) {
|
||||||
this.syncCompleted(false);
|
this.syncCompleted(false, userId);
|
||||||
throw e;
|
throw e;
|
||||||
} else {
|
} else {
|
||||||
return this.syncCompleted(false);
|
return this.syncCompleted(false, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Observable } from "rxjs";
|
import { Observable, Subscription } from "rxjs";
|
||||||
|
|
||||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
@@ -43,4 +43,9 @@ export abstract class TaskService {
|
|||||||
* @param userId - The user who is completing the task.
|
* @param userId - The user who is completing the task.
|
||||||
*/
|
*/
|
||||||
abstract markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise<void>;
|
abstract markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subscription for pending security task notifications or completed syncs for unlocked users.
|
||||||
|
*/
|
||||||
|
abstract listenForTaskNotifications(): Subscription;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
|
import { NotificationType } from "@bitwarden/common/enums";
|
||||||
|
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { Message, MessageListener } from "@bitwarden/common/platform/messaging";
|
||||||
|
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||||
@@ -16,10 +22,13 @@ import { DefaultTaskService } from "./default-task.service";
|
|||||||
describe("Default task service", () => {
|
describe("Default task service", () => {
|
||||||
let fakeStateProvider: FakeStateProvider;
|
let fakeStateProvider: FakeStateProvider;
|
||||||
|
|
||||||
|
const userId = "user-id" as UserId;
|
||||||
const mockApiSend = jest.fn();
|
const mockApiSend = jest.fn();
|
||||||
const mockGetAllOrgs$ = jest.fn();
|
const mockGetAllOrgs$ = jest.fn();
|
||||||
const mockGetFeatureFlag$ = jest.fn();
|
const mockGetFeatureFlag$ = jest.fn();
|
||||||
|
const mockAuthStatuses$ = new BehaviorSubject<Record<UserId, AuthenticationStatus>>({});
|
||||||
|
const mockNotifications$ = new Subject<readonly [NotificationResponse, UserId]>();
|
||||||
|
const mockMessages$ = new Subject<Message<Record<string, unknown>>>();
|
||||||
let service: DefaultTaskService;
|
let service: DefaultTaskService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -27,12 +36,15 @@ describe("Default task service", () => {
|
|||||||
mockGetAllOrgs$.mockClear();
|
mockGetAllOrgs$.mockClear();
|
||||||
mockGetFeatureFlag$.mockClear();
|
mockGetFeatureFlag$.mockClear();
|
||||||
|
|
||||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
||||||
service = new DefaultTaskService(
|
service = new DefaultTaskService(
|
||||||
fakeStateProvider,
|
fakeStateProvider,
|
||||||
{ send: mockApiSend } as unknown as ApiService,
|
{ send: mockApiSend } as unknown as ApiService,
|
||||||
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
|
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
|
||||||
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
|
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
|
||||||
|
{ authStatuses$: mockAuthStatuses$.asObservable() } as unknown as AuthService,
|
||||||
|
{ notifications$: mockNotifications$.asObservable() } as unknown as NotificationsService,
|
||||||
|
{ allMessages$: mockMessages$.asObservable() } as unknown as MessageListener,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,4 +269,235 @@ describe("Default task service", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listenForTaskNotifications()", () => {
|
||||||
|
it("should not subscribe to notifications when there are no unlocked users", () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Locked,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
|
||||||
|
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
expect(notificationHelper$).not.toHaveBeenCalled();
|
||||||
|
expect(syncCompletedHelper$).not.toHaveBeenCalled();
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not subscribe to notifications when no users have tasks enabled", () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(false));
|
||||||
|
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
|
||||||
|
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
expect(notificationHelper$).not.toHaveBeenCalled();
|
||||||
|
expect(syncCompletedHelper$).not.toHaveBeenCalled();
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should subscribe to notifications when there are unlocked users with tasks enabled", () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
|
||||||
|
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
|
||||||
|
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
expect(notificationHelper$).toHaveBeenCalled();
|
||||||
|
expect(syncCompletedHelper$).toHaveBeenCalled();
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notification handling", () => {
|
||||||
|
it("should refresh tasks when a notification is received for an allowed user", async () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
|
||||||
|
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
|
||||||
|
() => new Subject(),
|
||||||
|
));
|
||||||
|
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
const notification = {
|
||||||
|
type: NotificationType.PendingSecurityTasks,
|
||||||
|
} as NotificationResponse;
|
||||||
|
mockNotifications$.next([notification, userId]);
|
||||||
|
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(syncCompletedHelper$).toHaveBeenCalled();
|
||||||
|
expect(refreshTasks).toHaveBeenCalledWith(userId);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore notifications for other users", async () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
|
||||||
|
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
|
||||||
|
() => new Subject(),
|
||||||
|
));
|
||||||
|
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
const notification = {
|
||||||
|
type: NotificationType.PendingSecurityTasks,
|
||||||
|
} as NotificationResponse;
|
||||||
|
mockNotifications$.next([notification, "other-user-id" as UserId]);
|
||||||
|
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(syncCompletedHelper$).toHaveBeenCalled();
|
||||||
|
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore other notifications types", async () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
|
||||||
|
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
|
||||||
|
() => new Subject(),
|
||||||
|
));
|
||||||
|
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
const notification = {
|
||||||
|
type: NotificationType.SyncSettings,
|
||||||
|
} as NotificationResponse;
|
||||||
|
mockNotifications$.next([notification, userId]);
|
||||||
|
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(syncCompletedHelper$).toHaveBeenCalled();
|
||||||
|
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sync completed handling", () => {
|
||||||
|
it("should refresh tasks when a sync completed message is received for an allowed user", async () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
|
||||||
|
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
|
||||||
|
() => new Subject(),
|
||||||
|
));
|
||||||
|
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
mockMessages$.next({
|
||||||
|
command: "syncCompleted",
|
||||||
|
userId,
|
||||||
|
successfully: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(notificationHelper$).toHaveBeenCalled();
|
||||||
|
expect(refreshTasks).toHaveBeenCalledWith(userId);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore non syncCompleted messages", async () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
|
||||||
|
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
|
||||||
|
() => new Subject(),
|
||||||
|
));
|
||||||
|
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
mockMessages$.next({
|
||||||
|
command: "other-command",
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(notificationHelper$).toHaveBeenCalled();
|
||||||
|
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore failed sync messages", async () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
|
||||||
|
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
|
||||||
|
() => new Subject(),
|
||||||
|
));
|
||||||
|
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
mockMessages$.next({
|
||||||
|
command: "syncCompleted",
|
||||||
|
userId,
|
||||||
|
successfully: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(notificationHelper$).toHaveBeenCalled();
|
||||||
|
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore sync messages for other users", async () => {
|
||||||
|
mockAuthStatuses$.next({
|
||||||
|
[userId]: AuthenticationStatus.Unlocked,
|
||||||
|
});
|
||||||
|
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||||
|
|
||||||
|
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
|
||||||
|
() => new Subject(),
|
||||||
|
));
|
||||||
|
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||||
|
|
||||||
|
const subscription = service.listenForTaskNotifications();
|
||||||
|
|
||||||
|
mockMessages$.next({
|
||||||
|
command: "syncCompleted",
|
||||||
|
userId: "other-user-id" as UserId,
|
||||||
|
successfully: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(notificationHelper$).toHaveBeenCalled();
|
||||||
|
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { combineLatest, map, switchMap } from "rxjs";
|
import { combineLatest, filter, map, merge, Observable, of, Subscription, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
|
import { NotificationType } from "@bitwarden/common/enums";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||||
|
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
@@ -14,12 +19,21 @@ import { SecurityTaskStatus } from "../enums";
|
|||||||
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";
|
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";
|
||||||
import { SECURITY_TASKS } from "../state/security-task.state";
|
import { SECURITY_TASKS } from "../state/security-task.state";
|
||||||
|
|
||||||
|
const getUnlockedUserIds = map<Record<UserId, AuthenticationStatus>, UserId[]>((authStatuses) =>
|
||||||
|
Object.entries(authStatuses ?? {})
|
||||||
|
.filter(([, status]) => status >= AuthenticationStatus.Unlocked)
|
||||||
|
.map(([userId]) => userId as UserId),
|
||||||
|
);
|
||||||
|
|
||||||
export class DefaultTaskService implements TaskService {
|
export class DefaultTaskService implements TaskService {
|
||||||
constructor(
|
constructor(
|
||||||
private stateProvider: StateProvider,
|
private stateProvider: StateProvider,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private authService: AuthService,
|
||||||
|
private notificationService: NotificationsService,
|
||||||
|
private messageListener: MessageListener,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
tasksEnabled$ = perUserCache$((userId) => {
|
tasksEnabled$ = perUserCache$((userId) => {
|
||||||
@@ -36,6 +50,7 @@ export class DefaultTaskService implements TaskService {
|
|||||||
switchMap(async (tasks) => {
|
switchMap(async (tasks) => {
|
||||||
if (tasks == null) {
|
if (tasks == null) {
|
||||||
await this.fetchTasksFromApi(userId);
|
await this.fetchTasksFromApi(userId);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return tasks;
|
return tasks;
|
||||||
}),
|
}),
|
||||||
@@ -97,4 +112,66 @@ export class DefaultTaskService implements TaskService {
|
|||||||
): Promise<SecurityTaskData[] | null> {
|
): Promise<SecurityTaskData[] | null> {
|
||||||
return this.taskState(userId).update(() => tasks);
|
return this.taskState(userId).update(() => tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper observable that filters the list of unlocked user IDs to only those with tasks enabled.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private getOnlyTaskEnabledUsers = switchMap<UserId[], Observable<UserId[]>>((unlockedUserIds) => {
|
||||||
|
if (unlockedUserIds.length === 0) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return combineLatest(
|
||||||
|
unlockedUserIds.map((userId) =>
|
||||||
|
this.tasksEnabled$(userId).pipe(map((enabled) => (enabled ? userId : null))),
|
||||||
|
),
|
||||||
|
).pipe(map((userIds) => userIds.filter((userId) => userId !== null) as UserId[]));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper observable that emits whenever a security task notification is received for a user in the provided list.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private securityTaskNotifications$(filterByUserIds: UserId[]) {
|
||||||
|
return this.notificationService.notifications$.pipe(
|
||||||
|
filter(
|
||||||
|
([notification, userId]) =>
|
||||||
|
notification.type === NotificationType.PendingSecurityTasks &&
|
||||||
|
filterByUserIds.includes(userId),
|
||||||
|
),
|
||||||
|
map(([, userId]) => userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper observable that emits whenever a sync is completed for a user in the provided list.
|
||||||
|
*/
|
||||||
|
private syncCompletedMessage$(filterByUserIds: UserId[]) {
|
||||||
|
return this.messageListener.allMessages$.pipe(
|
||||||
|
filter((msg) => msg.command === "syncCompleted" && !!msg.successfully && !!msg.userId),
|
||||||
|
map((msg) => msg.userId as UserId),
|
||||||
|
filter((userId) => filterByUserIds.includes(userId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subscription for pending security task notifications or completed syncs for unlocked users.
|
||||||
|
*/
|
||||||
|
listenForTaskNotifications(): Subscription {
|
||||||
|
return this.authService.authStatuses$
|
||||||
|
.pipe(
|
||||||
|
getUnlockedUserIds,
|
||||||
|
this.getOnlyTaskEnabledUsers,
|
||||||
|
filter((allowedUserIds) => allowedUserIds.length > 0),
|
||||||
|
switchMap((allowedUserIds) =>
|
||||||
|
merge(
|
||||||
|
this.securityTaskNotifications$(allowedUserIds),
|
||||||
|
this.syncCompletedMessage$(allowedUserIds),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
switchMap((userId) => this.refreshTasks(userId)),
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user