From 6f1661e2472e73b2132d43ea203c4ee5002f06ce Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 25 Aug 2025 16:24:04 -0400 Subject: [PATCH] feat(inactive-user-server-notification): [PM-25130] Inactive User Server Notify - Added feature flag. --- .../browser/src/background/main.background.ts | 1 + libs/common/src/enums/feature-flag.enum.ts | 2 + ...ult-server-notifications.multiuser.spec.ts | 38 ++++++------- ...fault-server-notifications.service.spec.ts | 12 +++++ .../default-server-notifications.service.ts | 54 ++++++++++++++----- 5 files changed, 76 insertions(+), 31 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 2f87e347410..ce8833420ed 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1139,6 +1139,7 @@ export default class MainBackground { new SignalRConnectionService(this.apiService, this.logService), this.authService, this.webPushConnectionService, + this.configService, ); this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 7ab85139f4c..0ec9304d639 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -53,6 +53,7 @@ export enum FeatureFlag { /* Platform */ IpcChannelFramework = "ipc-channel-framework", + InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -112,6 +113,7 @@ export const DefaultFeatureFlagValue = { /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, + [FeatureFlag.InactiveUserServerNotification]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts index 7c0baef0318..a23de970247 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -3,6 +3,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; // TODO: When PM-14943 goes in, uncomment // import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; @@ -34,7 +35,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { let webPushNotificationConnectionService: MockProxy; // TODO: When PM-14943 goes in, uncomment // let authRequestAnsweringService: MockProxy; - let configurationService: MockProxy; + let configService: MockProxy; let activeUserAccount$: BehaviorSubject>; let userAccounts$: BehaviorSubject>; @@ -89,7 +90,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { signalRNotificationConnectionService = mock(); connectionSubjectByUser = new Map(); - (signalRNotificationConnectionService.connect$ as unknown as jest.Mock).mockImplementation( + signalRNotificationConnectionService.connect$.mockImplementation( (userId: UserId, _url: string) => { if (!connectionSubjectByUser.has(userId)) { connectionSubjectByUser.set(userId, new Subject()); @@ -100,7 +101,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { authService = mock(); authenticationStatusByUser = new Map(); - (authService.authStatusFor$ as unknown as jest.Mock).mockImplementation((userId: UserId) => { + authService.authStatusFor$.mockImplementation((userId: UserId) => { if (!authenticationStatusByUser.has(userId)) { authenticationStatusByUser.set( userId, @@ -112,9 +113,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { webPushNotificationConnectionService = mock(); webPushSupportStatusByUser = new Map(); - ( - webPushNotificationConnectionService.supportStatus$ as unknown as jest.Mock - ).mockImplementation((userId: UserId) => { + webPushNotificationConnectionService.supportStatus$.mockImplementation((userId: UserId) => { if (!webPushSupportStatusByUser.has(userId)) { webPushSupportStatusByUser.set( userId, @@ -127,8 +126,13 @@ describe("DefaultServerNotificationsService (multi-user)", () => { // TODO: When PM-14943 goes in, uncomment // authRequestAnsweringService = mock(); - configurationService = mock(); - configurationService.getFeatureFlag$.mockReturnValue(new BehaviorSubject(true) as any); + configService = mock(); + configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + const flagValueByFlag: Partial> = { + [FeatureFlag.InactiveUserServerNotification]: true, + }; + return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; + }); defaultServerNotificationsService = new DefaultServerNotificationsService( mock(), @@ -142,7 +146,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { authService, webPushNotificationConnectionService, // authRequestAnsweringService, - // configurationService, + configService, ); }); @@ -254,15 +258,13 @@ describe("DefaultServerNotificationsService (multi-user)", () => { const subscription = defaultServerNotificationsService.startListening(); // Emit via SignalR connect$ for user2 - connectionSubjectByUser - .get(mockUserId2)! - .next({ - type: "ReceiveMessage", - message: new NotificationResponse({ - type: NotificationType.AuthRequest, - payload: { id: "auth-id-2", userId: mockUserId2 }, - }), - }); + connectionSubjectByUser.get(mockUserId2)!.next({ + type: "ReceiveMessage", + message: new NotificationResponse({ + type: NotificationType.AuthRequest, + payload: { id: "auth-id-2", userId: mockUserId2 }, + }), + }); // allow async queue to drain await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts index c0ea893d539..c4faf2d8e52 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts @@ -4,6 +4,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { awaitAsync } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; @@ -14,6 +15,7 @@ import { NotificationType } from "../../../enums"; import { NotificationResponse } from "../../../models/response/notification.response"; import { UserId } from "../../../types/guid"; import { AppIdService } from "../../abstractions/app-id.service"; +import { ConfigService } from "../../abstractions/config/config.service"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; import { LogService } from "../../abstractions/log.service"; import { MessageSender } from "../../messaging"; @@ -38,6 +40,7 @@ describe("NotificationsService", () => { let signalRNotificationConnectionService: MockProxy; let authService: MockProxy; let webPushNotificationConnectionService: MockProxy; + let configService: MockProxy; let activeAccount: BehaviorSubject>; let accounts: BehaviorSubject>; @@ -65,6 +68,14 @@ describe("NotificationsService", () => { signalRNotificationConnectionService = mock(); authService = mock(); webPushNotificationConnectionService = mock(); + configService = mock(); + // For these tests, use the active-user implementation (feature flag disabled) + configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + const flagValueByFlag: Partial> = { + [FeatureFlag.InactiveUserServerNotification]: false, + }; + return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; + }); activeAccount = new BehaviorSubject>(null); accountService.activeAccount$ = activeAccount.asObservable(); @@ -108,6 +119,7 @@ describe("NotificationsService", () => { signalRNotificationConnectionService, authService, webPushNotificationConnectionService, + configService, ); }); diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index 4758c06cf73..c4e53692d7b 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -16,6 +16,8 @@ import { // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; @@ -57,22 +59,48 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer private readonly signalRConnectionService: SignalRConnectionService, private readonly authService: AuthService, private readonly webPushConnectionService: WebPushConnectionService, + private readonly configService: ConfigService, ) { - this.notifications$ = this.accountService.accounts$.pipe( - map((accounts) => Object.keys(accounts) as UserId[]), - switchMap((userIds) => { - if (userIds.length === 0) { - return EMPTY; - } + this.notifications$ = this.configService + .getFeatureFlag$(FeatureFlag.InactiveUserServerNotification) + .pipe( + switchMap((inactiveUserServerNotificationEnabled) => { + if (inactiveUserServerNotificationEnabled) { + return this.accountService.accounts$.pipe( + map((accounts) => Object.keys(accounts) as UserId[]), + switchMap((userIds) => { + if (userIds.length === 0) { + return EMPTY; + } - const streams = userIds.map((id) => - this.userNotifications$(id).pipe(map((notification) => [notification, id] as const)), - ); + const streams = userIds.map((id) => + this.userNotifications$(id).pipe( + map((notification) => [notification, id] as const), + ), + ); - return merge(...streams); - }), - share(), // Multiple subscribers should only create a single connection to the server per subscriber - ); + return merge(...streams); + }), + ); + } + + return this.accountService.activeAccount$.pipe( + map((account) => account?.id), + distinctUntilChanged(), + switchMap((activeAccountId) => { + if (activeAccountId == null) { + // We don't emit server-notifications for inactive accounts currently + return EMPTY; + } + + return this.userNotifications$(activeAccountId).pipe( + map((notification) => [notification, activeAccountId] as const), + ); + }), + ); + }), + share(), // Multiple subscribers should only create a single connection to the server + ); } /**