1
0
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:
Shane Melton
2025-04-04 13:42:44 -07:00
committed by GitHub
parent 1af8fe2012
commit a7fe4877d7
9 changed files with 400 additions and 39 deletions

View File

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

View File

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

View File

@@ -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,

View File

@@ -26,4 +26,6 @@ export enum NotificationType {
SyncOrganizationCollectionSettingChanged = 19, SyncOrganizationCollectionSettingChanged = 19,
Notification = 20, Notification = 20,
NotificationStatus = 21, NotificationStatus = 21,
PendingSecurityTasks = 22,
} }

View File

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

View File

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

View File

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

View File

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

View File

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