mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
Add Web Push Support (#11346)
* WIP: PoC with lots of terrible code with web push * fix service worker building * Work on WebPush Tailored to Browser * Clean Up Web And MV2 * Fix Merge Conflicts * Prettier * Use Unsupported for MV2 * Add Doc Comments * Remove Permission Button * Fix Type Test * Write Time In More Readable Format * Add SignalR Logger * `sheduleReconnect` -> `scheduleReconnect` Co-authored-by: Matt Gibson <mgibson@bitwarden.com> * Capture Support Context In Connector * Remove Unneeded CSP Change * Fix Build * Simplify `getOrCreateSubscription` * Add More Docs to Matrix * Update libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts Co-authored-by: Matt Gibson <mgibson@bitwarden.com> * Move API Service Into Notifications Folder * Allow Connection When Account Is Locked * Add Comments to NotificationsService * Only Change Support Status If Public Key Changes * Move Service Choice Out To Method * Use Named Constant For Disabled Notification Url * Add Test & Cleanup * Flatten * Move Tests into `beforeEach` & `afterEach` * Add Tests * Test `distinctUntilChanged`'s Operators More * Make Helper And Cleanup Chain * Add Back Cast * Add extra safety to incoming config check * Put data through response object * Apply TS Strict Rules * Finish PushTechnology comment * Use `instanceof` check * Do Safer Worker Based Registration for MV3 * Remove TODO * Switch to SignalR on any WebPush Error * Fix Manifest Permissions * Add Back `webNavigation` * Sorry, Remove `webNavigation` * Fixed merge conflicts. --------- Co-authored-by: Matt Gibson <mgibson@bitwarden.com> Co-authored-by: Todd Martin <tmartin@bitwarden.com> Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
import { awaitAsync } from "../../../../spec";
|
||||
import { Matrix } from "../../../../spec/matrix";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { NotificationType } from "../../../enums";
|
||||
import { NotificationResponse } from "../../../models/response/notification.response";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { AppIdService } from "../../abstractions/app-id.service";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { MessageSender } from "../../messaging";
|
||||
import { SupportStatus } from "../../misc/support-status";
|
||||
import { SyncService } from "../../sync";
|
||||
|
||||
import {
|
||||
DefaultNotificationsService,
|
||||
DISABLED_NOTIFICATIONS_URL,
|
||||
} from "./default-notifications.service";
|
||||
import { SignalRNotification, SignalRConnectionService } from "./signalr-connection.service";
|
||||
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
|
||||
import { WorkerWebPushConnectionService } from "./worker-webpush-connection.service";
|
||||
|
||||
describe("NotificationsService", () => {
|
||||
let syncService: MockProxy<SyncService>;
|
||||
let appIdService: MockProxy<AppIdService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason]>;
|
||||
let messagingService: MockProxy<MessageSender>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
|
||||
|
||||
let activeAccount: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
|
||||
|
||||
let environment: BehaviorSubject<ObservedValueOf<EnvironmentService["environment$"]>>;
|
||||
|
||||
let authStatusGetter: (userId: UserId) => BehaviorSubject<AuthenticationStatus>;
|
||||
|
||||
let webPushSupportGetter: (userId: UserId) => BehaviorSubject<SupportStatus<WebPushConnector>>;
|
||||
|
||||
let signalrNotificationGetter: (
|
||||
userId: UserId,
|
||||
notificationsUrl: string,
|
||||
) => Subject<SignalRNotification>;
|
||||
|
||||
let sut: DefaultNotificationsService;
|
||||
|
||||
beforeEach(() => {
|
||||
syncService = mock<SyncService>();
|
||||
appIdService = mock<AppIdService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
logoutCallback = jest.fn<Promise<void>, [logoutReason: LogoutReason]>();
|
||||
messagingService = mock<MessageSender>();
|
||||
accountService = mock<AccountService>();
|
||||
signalRNotificationConnectionService = mock<SignalRConnectionService>();
|
||||
authService = mock<AuthService>();
|
||||
webPushNotificationConnectionService = mock<WorkerWebPushConnectionService>();
|
||||
|
||||
activeAccount = new BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>(null);
|
||||
accountService.activeAccount$ = activeAccount.asObservable();
|
||||
|
||||
environment = new BehaviorSubject<ObservedValueOf<EnvironmentService["environment$"]>>({
|
||||
getNotificationsUrl: () => "https://notifications.bitwarden.com",
|
||||
} as Environment);
|
||||
|
||||
environmentService.environment$ = environment;
|
||||
|
||||
authStatusGetter = Matrix.autoMockMethod(
|
||||
authService.authStatusFor$,
|
||||
() => new BehaviorSubject<AuthenticationStatus>(AuthenticationStatus.LoggedOut),
|
||||
);
|
||||
|
||||
webPushSupportGetter = Matrix.autoMockMethod(
|
||||
webPushNotificationConnectionService.supportStatus$,
|
||||
() =>
|
||||
new BehaviorSubject<SupportStatus<WebPushConnector>>({
|
||||
type: "not-supported",
|
||||
reason: "test",
|
||||
}),
|
||||
);
|
||||
|
||||
signalrNotificationGetter = Matrix.autoMockMethod(
|
||||
signalRNotificationConnectionService.connect$,
|
||||
() => new Subject<SignalRNotification>(),
|
||||
);
|
||||
|
||||
sut = new DefaultNotificationsService(
|
||||
syncService,
|
||||
appIdService,
|
||||
environmentService,
|
||||
logoutCallback,
|
||||
messagingService,
|
||||
accountService,
|
||||
signalRNotificationConnectionService,
|
||||
authService,
|
||||
webPushNotificationConnectionService,
|
||||
mock<LogService>(),
|
||||
);
|
||||
});
|
||||
|
||||
const mockUser1 = "user1" as UserId;
|
||||
const mockUser2 = "user2" as UserId;
|
||||
|
||||
function emitActiveUser(userId: UserId) {
|
||||
if (userId == null) {
|
||||
activeAccount.next(null);
|
||||
} else {
|
||||
activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true });
|
||||
}
|
||||
}
|
||||
|
||||
function emitNotificationUrl(url: string) {
|
||||
environment.next({
|
||||
getNotificationsUrl: () => url,
|
||||
} as Environment);
|
||||
}
|
||||
|
||||
const expectNotification = (
|
||||
notification: readonly [NotificationResponse, UserId],
|
||||
expectedUser: UserId,
|
||||
expectedType: NotificationType,
|
||||
) => {
|
||||
const [actualNotification, actualUser] = notification;
|
||||
expect(actualUser).toBe(expectedUser);
|
||||
expect(actualNotification.type).toBe(expectedType);
|
||||
};
|
||||
|
||||
it("emits notifications through WebPush when supported", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
|
||||
const webPush = mock<WebPushConnector>();
|
||||
const webPushSubject = new Subject<NotificationResponse>();
|
||||
webPush.notifications$ = webPushSubject;
|
||||
|
||||
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
|
||||
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderCreate }));
|
||||
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderDelete }));
|
||||
|
||||
const notifications = await notificationsPromise;
|
||||
expectNotification(notifications[0], mockUser1, NotificationType.SyncFolderCreate);
|
||||
expectNotification(notifications[1], mockUser1, NotificationType.SyncFolderDelete);
|
||||
});
|
||||
|
||||
it("switches to SignalR when web push is not supported.", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
|
||||
const webPush = mock<WebPushConnector>();
|
||||
const webPushSubject = new Subject<NotificationResponse>();
|
||||
webPush.notifications$ = webPushSubject;
|
||||
|
||||
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
|
||||
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderCreate }));
|
||||
|
||||
emitActiveUser(mockUser2);
|
||||
authStatusGetter(mockUser2).next(AuthenticationStatus.Unlocked);
|
||||
// Second user does not support web push
|
||||
webPushSupportGetter(mockUser2).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
signalrNotificationGetter(mockUser2, "http://test.example.com").next({
|
||||
type: "ReceiveMessage",
|
||||
message: new NotificationResponse({ type: NotificationType.SyncCipherUpdate }),
|
||||
});
|
||||
|
||||
const notifications = await notificationsPromise;
|
||||
expectNotification(notifications[0], mockUser1, NotificationType.SyncFolderCreate);
|
||||
expectNotification(notifications[1], mockUser2, NotificationType.SyncCipherUpdate);
|
||||
});
|
||||
|
||||
it("switches to WebPush when it becomes supported.", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
signalrNotificationGetter(mockUser1, "http://test.example.com").next({
|
||||
type: "ReceiveMessage",
|
||||
message: new NotificationResponse({ type: NotificationType.AuthRequest }),
|
||||
});
|
||||
|
||||
const webPush = mock<WebPushConnector>();
|
||||
const webPushSubject = new Subject<NotificationResponse>();
|
||||
webPush.notifications$ = webPushSubject;
|
||||
|
||||
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
|
||||
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncLoginDelete }));
|
||||
|
||||
const notifications = await notificationsPromise;
|
||||
expectNotification(notifications[0], mockUser1, NotificationType.AuthRequest);
|
||||
expectNotification(notifications[1], mockUser1, NotificationType.SyncLoginDelete);
|
||||
});
|
||||
|
||||
it("does not emit SignalR heartbeats", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(1)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
signalrNotificationGetter(mockUser1, "http://test.example.com").next({ type: "Heartbeat" });
|
||||
signalrNotificationGetter(mockUser1, "http://test.example.com").next({
|
||||
type: "ReceiveMessage",
|
||||
message: new NotificationResponse({ type: NotificationType.AuthRequestResponse }),
|
||||
});
|
||||
|
||||
const notifications = await notificationsPromise;
|
||||
expectNotification(notifications[0], mockUser1, NotificationType.AuthRequestResponse);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
|
||||
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
|
||||
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
|
||||
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Unlocked },
|
||||
])(
|
||||
"does not re-connect when the user transitions from $initialStatus to $updatedStatus",
|
||||
async ({ initialStatus, updatedStatus }) => {
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(initialStatus);
|
||||
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
const notificationsSubscriptions = sut.notifications$.subscribe();
|
||||
await awaitAsync(1);
|
||||
|
||||
authStatusGetter(mockUser1).next(updatedStatus);
|
||||
await awaitAsync(1);
|
||||
|
||||
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1);
|
||||
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith(
|
||||
mockUser1,
|
||||
"http://test.example.com",
|
||||
);
|
||||
notificationsSubscriptions.unsubscribe();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])(
|
||||
"connects when a user transitions from logged out to %s",
|
||||
async (newStatus: AuthenticationStatus) => {
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.LoggedOut);
|
||||
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
const notificationsSubscriptions = sut.notifications$.subscribe();
|
||||
await awaitAsync(1);
|
||||
|
||||
authStatusGetter(mockUser1).next(newStatus);
|
||||
await awaitAsync(1);
|
||||
|
||||
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1);
|
||||
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith(
|
||||
mockUser1,
|
||||
"http://test.example.com",
|
||||
);
|
||||
notificationsSubscriptions.unsubscribe();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not connect to any notification stream when notifications are disabled through special url", () => {
|
||||
const subscription = sut.notifications$.subscribe();
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl(DISABLED_NOTIFICATIONS_URL);
|
||||
|
||||
expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled();
|
||||
expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("does not connect to any notification stream when there is no active user", () => {
|
||||
const subscription = sut.notifications$.subscribe();
|
||||
emitActiveUser(null);
|
||||
|
||||
expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled();
|
||||
expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("does not reconnect if the same notification url is emitted", async () => {
|
||||
const subscription = sut.notifications$.subscribe();
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
|
||||
await awaitAsync(1);
|
||||
|
||||
expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
|
||||
await awaitAsync(1);
|
||||
|
||||
expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user