1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-28 02:23:25 +00:00

Merge branch 'main' into SM-1301-getbyidsevent

This commit is contained in:
cd-bitwarden
2025-07-01 11:00:36 -04:00
committed by GitHub
402 changed files with 11624 additions and 1529 deletions

View File

@@ -1,119 +1 @@
import { MockProxy, mock } from "jest-mock-extended";
import { Subject } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
StorageUpdate,
} from "../src/platform/abstractions/storage.service";
import { StorageOptions } from "../src/platform/models/domain/storage-options";
const INTERNAL_KEY = "__internal__";
export class FakeStorageService implements AbstractStorageService, ObservableStorageService {
private store: Record<string, unknown>;
private updatesSubject = new Subject<StorageUpdate>();
private _valuesRequireDeserialization = false;
/**
* Returns a mock of a {@see AbstractStorageService} for asserting the expected
* amount of calls. It is not recommended to use this to mock implementations as
* they are not respected.
*/
mock: MockProxy<AbstractStorageService>;
constructor(initial?: Record<string, unknown>) {
this.store = initial ?? {};
this.mock = mock<AbstractStorageService>();
}
/**
* Updates the internal store for this fake implementation, this bypasses any mock calls
* or updates to the {@link updates$} observable.
* @param store
*/
internalUpdateStore(store: Record<string, unknown>) {
this.store = store;
}
get internalStore() {
return this.store;
}
internalUpdateValuesRequireDeserialization(value: boolean) {
this._valuesRequireDeserialization = value;
}
get valuesRequireDeserialization(): boolean {
return this._valuesRequireDeserialization;
}
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string, options?: StorageOptions): Promise<T> {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.mock.get(key, options);
const value = this.store[key] as T;
return Promise.resolve(value);
}
has(key: string, options?: StorageOptions): Promise<boolean> {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.mock.has(key, options);
return Promise.resolve(this.store[key] != null);
}
async save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
// These exceptions are copied from https://github.com/sindresorhus/conf/blob/608adb0c46fb1680ddbd9833043478367a64c120/source/index.ts#L193-L203
// which is a library that is used by `ElectronStorageService`. We add them here to ensure that the behavior in our testing mirrors the real world.
if (typeof key !== "string" && typeof key !== "object") {
throw new TypeError(
`Expected \`key\` to be of type \`string\` or \`object\`, got ${typeof key}`,
);
}
// We don't throw this error because ElectronStorageService automatically detects this case
// and calls `delete()` instead of `set()`.
// if (typeof key !== "object" && obj === undefined) {
// throw new TypeError("Use `delete()` to clear values");
// }
if (this._containsReservedKey(key)) {
throw new TypeError(
`Please don't use the ${INTERNAL_KEY} key, as it's used to manage this module internal operations.`,
);
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.mock.save(key, obj, options);
this.store[key] = obj;
this.updatesSubject.next({ key: key, updateType: "save" });
}
remove(key: string, options?: StorageOptions): Promise<void> {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.mock.remove(key, options);
delete this.store[key];
this.updatesSubject.next({ key: key, updateType: "remove" });
return Promise.resolve();
}
private _containsReservedKey(key: string | Partial<unknown>): boolean {
if (typeof key === "object") {
const firsKey = Object.keys(key)[0];
if (firsKey === INTERNAL_KEY) {
return true;
}
}
if (typeof key !== "string") {
return false;
}
return false;
}
}
export { FakeStorageService } from "@bitwarden/storage-test-utils";

View File

@@ -18,6 +18,7 @@ export class AuthRequestResponse extends BaseResponse {
responseDate?: string;
isAnswered: boolean;
isExpired: boolean;
deviceId?: string; // could be null or empty
constructor(response: any) {
super(response);
@@ -33,6 +34,7 @@ export class AuthRequestResponse extends BaseResponse {
this.creationDate = this.getResponseProperty("CreationDate");
this.requestApproved = this.getResponseProperty("RequestApproved");
this.responseDate = this.getResponseProperty("ResponseDate");
this.deviceId = this.getResponseProperty("RequestDeviceId");
const requestDate = new Date(this.creationDate);
const requestDateUTC = Date.UTC(

View File

@@ -12,13 +12,13 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
export enum FeatureFlag {
/* Admin Console Team */
SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions",
OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript",
CreateDefaultLocation = "pm-19467-create-default-location",
/* Auth */
PM16117_SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor",
PM16117_ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor",
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals",
/* Autofill */
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
@@ -45,7 +45,6 @@ export enum FeatureFlag {
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
/* Tools */
ItemShare = "item-share",
DesktopSendUIRefresh = "desktop-send-ui-refresh",
/* Vault */
@@ -77,7 +76,6 @@ const FALSE = false as boolean;
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.SeparateCustomRolePermissions]: FALSE,
[FeatureFlag.OptimizeNestedTraverseTypescript]: FALSE,
[FeatureFlag.CreateDefaultLocation]: FALSE,
/* Autofill */
@@ -91,7 +89,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
/* Tools */
[FeatureFlag.ItemShare]: FALSE,
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
/* Vault */
@@ -107,6 +104,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM16117_SetInitialPasswordRefactor]: FALSE,
[FeatureFlag.PM16117_ChangeExistingPasswordRefactor]: FALSE,
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
[FeatureFlag.PM14938_BrowserExtensionLoginApproval]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,

View File

@@ -14,7 +14,6 @@ import { LogoutReason } from "@bitwarden/auth/common";
import { BiometricsService } from "@bitwarden/key-management";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { SearchService } from "../../../abstractions/search.service";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
@@ -28,6 +27,7 @@ import { StateEventRunnerService } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "../../../vault/abstractions/search.service";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";

View File

@@ -12,7 +12,6 @@ import { LogoutReason } from "@bitwarden/auth/common";
// eslint-disable-next-line no-restricted-imports
import { BiometricsService } from "@bitwarden/key-management";
import { SearchService } from "../../../abstractions/search.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
@@ -25,6 +24,7 @@ import { StateEventRunnerService } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "../../../vault/abstractions/search.service";
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "../abstractions/vault-timeout-settings.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../abstractions/vault-timeout.service";

View File

@@ -124,9 +124,9 @@ export class CipherExport {
domain.passwordHistory = req.passwordHistory.map((ph) => PasswordHistoryExport.toDomain(ph));
}
domain.creationDate = req.creationDate;
domain.revisionDate = req.revisionDate;
domain.deletedDate = req.deletedDate;
domain.creationDate = req.creationDate ? new Date(req.creationDate) : null;
domain.revisionDate = req.revisionDate ? new Date(req.revisionDate) : null;
domain.deletedDate = req.deletedDate ? new Date(req.deletedDate) : null;
return domain;
}

View File

@@ -4,7 +4,7 @@ import { IpcClient, IncomingMessage, OutgoingMessage } from "@bitwarden/sdk-inte
export abstract class IpcService {
private _client?: IpcClient;
protected get client(): IpcClient {
get client(): IpcClient {
if (!this._client) {
throw new Error("IpcService not initialized");
}

View File

@@ -0,0 +1,387 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import {
awaitAsync,
FakeGlobalState,
FakeStateProvider,
mockAccountServiceWith,
} from "../../../../spec";
import { PushTechnology } from "../../../enums/push-technology.enum";
import { UserId } from "../../../types/guid";
import { ConfigService } from "../../abstractions/config/config.service";
import { ServerConfig } from "../../abstractions/config/server-config";
import { Supported } from "../../misc/support-status";
import { Utils } from "../../misc/utils";
import { ServerConfigData } from "../../models/data/server-config.data";
import { PushSettingsConfigResponse } from "../../models/response/server-config.response";
import { KeyDefinition } from "../../state";
import { WebPushNotificationsApiService } from "./web-push-notifications-api.service";
import { WebPushConnector } from "./webpush-connection.service";
import {
WEB_PUSH_SUBSCRIPTION_USERS,
WorkerWebPushConnectionService,
} from "./worker-webpush-connection.service";
const mockUser1 = "testUser1" as UserId;
const createSub = (key: string) => {
return {
options: { applicationServerKey: Utils.fromUrlB64ToArray(key), userVisibleOnly: true },
endpoint: `web.push.endpoint/?${Utils.newGuid()}`,
expirationTime: 5,
getKey: () => null,
toJSON: () => ({ endpoint: "something", keys: {}, expirationTime: 5 }),
unsubscribe: () => Promise.resolve(true),
} satisfies PushSubscription;
};
describe("WorkerWebpushConnectionService", () => {
let configService: MockProxy<ConfigService>;
let webPushApiService: MockProxy<WebPushNotificationsApiService>;
let stateProvider: FakeStateProvider;
let pushManager: MockProxy<PushManager>;
const userId = "testUser1" as UserId;
let sut: WorkerWebPushConnectionService;
beforeEach(() => {
configService = mock();
webPushApiService = mock();
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
pushManager = mock();
sut = new WorkerWebPushConnectionService(
configService,
webPushApiService,
mock<ServiceWorkerRegistration>({ pushManager: pushManager }),
stateProvider,
);
});
afterEach(() => {
jest.resetAllMocks();
});
type ExtractKeyDefinitionType<T> = T extends KeyDefinition<infer U> ? U : never;
describe("supportStatus$", () => {
let fakeGlobalState: FakeGlobalState<
ExtractKeyDefinitionType<typeof WEB_PUSH_SUBSCRIPTION_USERS>
>;
beforeEach(() => {
fakeGlobalState = stateProvider.getGlobal(WEB_PUSH_SUBSCRIPTION_USERS) as FakeGlobalState<
ExtractKeyDefinitionType<typeof WEB_PUSH_SUBSCRIPTION_USERS>
>;
});
test("when web push is supported, have an existing subscription, and we've already registered the user, should not call API", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: "dGVzdA",
}),
}),
),
);
const existingSubscription = createSub("dGVzdA");
await fakeGlobalState.nextState({ [existingSubscription.endpoint]: [userId] });
pushManager.getSubscription.mockResolvedValue(existingSubscription);
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("supported");
const service = (supportStatus as Supported<WebPushConnector>).service;
expect(service).not.toBeFalsy();
const notificationsSub = service.notifications$.subscribe();
await awaitAsync(2);
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(0);
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(0);
notificationsSub.unsubscribe();
});
test("when web push is supported, have an existing subscription, and we haven't registered the user, should call API", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: "dGVzdA",
}),
}),
),
);
const existingSubscription = createSub("dGVzdA");
await fakeGlobalState.nextState({
[existingSubscription.endpoint]: ["otherUserId" as UserId],
});
pushManager.getSubscription.mockResolvedValue(existingSubscription);
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("supported");
const service = (supportStatus as Supported<WebPushConnector>).service;
expect(service).not.toBeFalsy();
const notificationsSub = service.notifications$.subscribe();
await awaitAsync(2);
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(1);
expect(fakeGlobalState.nextMock).toHaveBeenCalledWith({
[existingSubscription.endpoint]: ["otherUserId", mockUser1],
});
notificationsSub.unsubscribe();
});
test("when web push is supported, have an existing subscription, but it isn't in state, should call API and add to state", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: "dGVzdA",
}),
}),
),
);
const existingSubscription = createSub("dGVzdA");
await fakeGlobalState.nextState({
[existingSubscription.endpoint]: null!,
});
pushManager.getSubscription.mockResolvedValue(existingSubscription);
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("supported");
const service = (supportStatus as Supported<WebPushConnector>).service;
expect(service).not.toBeFalsy();
const notificationsSub = service.notifications$.subscribe();
await awaitAsync(2);
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(1);
expect(fakeGlobalState.nextMock).toHaveBeenCalledWith({
[existingSubscription.endpoint]: [mockUser1],
});
notificationsSub.unsubscribe();
});
test("when web push is supported, have an existing subscription, but state array is null, should call API and add to state", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: "dGVzdA",
}),
}),
),
);
const existingSubscription = createSub("dGVzdA");
await fakeGlobalState.nextState({});
pushManager.getSubscription.mockResolvedValue(existingSubscription);
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("supported");
const service = (supportStatus as Supported<WebPushConnector>).service;
expect(service).not.toBeFalsy();
const notificationsSub = service.notifications$.subscribe();
await awaitAsync(2);
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(1);
expect(fakeGlobalState.nextMock).toHaveBeenCalledWith({
[existingSubscription.endpoint]: [mockUser1],
});
notificationsSub.unsubscribe();
});
test("when web push is supported, but we don't have an existing subscription, should call the api and wipe out existing state", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: "dGVzdA",
}),
}),
),
);
const existingState = createSub("dGVzdA");
await fakeGlobalState.nextState({ [existingState.endpoint]: [userId] });
pushManager.getSubscription.mockResolvedValue(null);
const newSubscription = createSub("dGVzdA");
pushManager.subscribe.mockResolvedValue(newSubscription);
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("supported");
const service = (supportStatus as Supported<WebPushConnector>).service;
expect(service).not.toBeFalsy();
const notificationsSub = service.notifications$.subscribe();
await awaitAsync(2);
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
expect(fakeGlobalState.nextMock).toHaveBeenCalledTimes(1);
expect(fakeGlobalState.nextMock).toHaveBeenCalledWith({
[newSubscription.endpoint]: [mockUser1],
});
notificationsSub.unsubscribe();
});
test("when web push is supported and no existing subscription, should call API", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: "dGVzdA",
}),
}),
),
);
pushManager.getSubscription.mockResolvedValue(null);
pushManager.subscribe.mockResolvedValue(createSub("dGVzdA"));
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("supported");
const service = (supportStatus as Supported<WebPushConnector>).service;
expect(service).not.toBeFalsy();
const notificationsSub = service.notifications$.subscribe();
await awaitAsync(2);
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
expect(pushManager.subscribe).toHaveBeenCalledTimes(1);
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
notificationsSub.unsubscribe();
});
test("when web push is supported and existing subscription with different key, should call API", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: "dGVzdA",
}),
}),
),
);
pushManager.getSubscription.mockResolvedValue(createSub("dGVzdF9hbHQ"));
pushManager.subscribe.mockResolvedValue(createSub("dGVzdA"));
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("supported");
const service = (supportStatus as Supported<WebPushConnector>).service;
expect(service).not.toBeFalsy();
const notificationsSub = service.notifications$.subscribe();
await awaitAsync(2);
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
expect(pushManager.subscribe).toHaveBeenCalledTimes(1);
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
notificationsSub.unsubscribe();
});
test("when server config emits multiple times quickly while api call takes a long time will only call API once", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: "dGVzdA",
}),
}),
),
);
pushManager.getSubscription.mockResolvedValue(createSub("dGVzdF9hbHQ"));
pushManager.subscribe.mockResolvedValue(createSub("dGVzdA"));
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("supported");
const service = (supportStatus as Supported<WebPushConnector>).service;
expect(service).not.toBeFalsy();
const notificationsSub = service.notifications$.subscribe();
await awaitAsync(2);
expect(pushManager.getSubscription).toHaveBeenCalledTimes(1);
expect(pushManager.subscribe).toHaveBeenCalledTimes(1);
expect(webPushApiService.putSubscription).toHaveBeenCalledTimes(1);
notificationsSub.unsubscribe();
});
it("server config shows SignalR support should return not-supported", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.SignalR,
}),
}),
),
);
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("not-supported");
});
it("server config shows web push but no public key support should return not-supported", async () => {
configService.serverConfig$ = of(
new ServerConfig(
new ServerConfigData({
push: new PushSettingsConfigResponse({
pushTechnology: PushTechnology.WebPush,
vapidPublicKey: null,
}),
}),
),
);
const supportStatus = await firstValueFrom(sut.supportStatus$(mockUser1));
expect(supportStatus.type).toBe("not-supported");
});
});
});

View File

@@ -9,6 +9,7 @@ import {
Subject,
Subscription,
switchMap,
withLatestFrom,
} from "rxjs";
import { PushTechnology } from "../../../enums/push-technology.enum";
@@ -17,6 +18,7 @@ import { UserId } from "../../../types/guid";
import { ConfigService } from "../../abstractions/config/config.service";
import { SupportStatus } from "../../misc/support-status";
import { Utils } from "../../misc/utils";
import { KeyDefinition, StateProvider, WEB_PUSH_SUBSCRIPTION } from "../../state";
import { WebPushNotificationsApiService } from "./web-push-notifications-api.service";
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
@@ -48,6 +50,7 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService
private readonly configService: ConfigService,
private readonly webPushApiService: WebPushNotificationsApiService,
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
private readonly stateProvider: StateProvider,
) {}
start(): Subscription {
@@ -97,6 +100,7 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService
this.serviceWorkerRegistration,
this.pushEvent,
this.pushChangeEvent,
this.stateProvider,
),
} satisfies SupportStatus<WebPushConnector>;
}),
@@ -114,20 +118,36 @@ class MyWebPushConnector implements WebPushConnector {
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
private readonly pushEvent$: Observable<PushEvent>,
private readonly pushChangeEvent$: Observable<PushSubscriptionChangeEvent>,
private readonly stateProvider: StateProvider,
) {
const subscriptionUsersState = this.stateProvider.getGlobal(WEB_PUSH_SUBSCRIPTION_USERS);
this.notifications$ = this.getOrCreateSubscription$(this.vapidPublicKey).pipe(
concatMap((subscription) => {
return defer(() => {
if (subscription == null) {
throw new Error("Expected a non-null subscription.");
}
return this.webPushApiService.putSubscription(subscription.toJSON());
}).pipe(
switchMap(() => this.pushEvent$),
map((e) => {
return new NotificationResponse(e.data.json().data);
}),
);
withLatestFrom(subscriptionUsersState.state$.pipe(map((x) => x ?? {}))),
concatMap(async ([[isExistingSubscription, subscription], subscriptionUsers]) => {
if (subscription == null) {
throw new Error("Expected a non-null subscription.");
}
// If this is a new subscription, we can clear state and start over
if (!isExistingSubscription) {
subscriptionUsers = {};
}
// If the user is already subscribed, we don't need to do anything
if (subscriptionUsers[subscription.endpoint]?.includes(this.userId)) {
return;
}
subscriptionUsers[subscription.endpoint] ??= [];
subscriptionUsers[subscription.endpoint].push(this.userId);
// Update the state with the new subscription-user association
await subscriptionUsersState.update(() => subscriptionUsers);
// Inform the server about the new subscription-user association
await this.webPushApiService.putSubscription(subscription.toJSON());
}),
switchMap(() => this.pushEvent$),
map((e) => {
return new NotificationResponse(e.data.json().data);
}),
);
}
@@ -146,7 +166,7 @@ class MyWebPushConnector implements WebPushConnector {
await this.serviceWorkerRegistration.pushManager.getSubscription();
if (existingSubscription == null) {
return await this.pushManagerSubscribe(key);
return [false, await this.pushManagerSubscribe(key)] as const;
}
const subscriptionKey = Utils.fromBufferToUrlB64(
@@ -159,12 +179,30 @@ class MyWebPushConnector implements WebPushConnector {
if (subscriptionKey !== key) {
// There is a subscription, but it's not for the current server, unsubscribe and then make a new one
await existingSubscription.unsubscribe();
return await this.pushManagerSubscribe(key);
return [false, await this.pushManagerSubscribe(key)] as const;
}
return existingSubscription;
return [true, existingSubscription] as const;
}),
this.pushChangeEvent$.pipe(map((event) => event.newSubscription)),
this.pushChangeEvent$.pipe(map((event) => [false, event.newSubscription] as const)),
);
}
}
export const WEB_PUSH_SUBSCRIPTION_USERS = new KeyDefinition<Record<string, UserId[]>>(
WEB_PUSH_SUBSCRIPTION,
"subUsers",
{
deserializer: (obj) => {
if (obj == null) {
return {};
}
const result: Record<string, UserId[]> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = Array.isArray(value) ? value : [];
}
return result;
},
},
);

View File

@@ -128,6 +128,9 @@ export const EXTENSION_INITIAL_INSTALL_DISK = new StateDefinition(
"extensionInitialInstall",
"disk",
);
export const WEB_PUSH_SUBSCRIPTION = new StateDefinition("webPushSubscription", "disk", {
web: "disk-local",
});
// Design System

View File

@@ -1813,6 +1813,11 @@ export class ApiService implements ApiServiceAbstraction {
if (authed) {
const authHeader = await this.getActiveBearerToken();
headers.set("Authorization", "Bearer " + authHeader);
} else {
// For unauthenticated requests, we need to tell the server what the device is for flag targeting,
// since it won't be able to get it from the access token.
const appId = await this.appIdService.getAppId();
headers.set("Device-Identifier", appId);
}
if (body != null) {

View File

@@ -2,9 +2,9 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
import { SendView } from "../tools/send/models/view/send.view";
import { IndexedEntityId, UserId } from "../types/guid";
import { CipherView } from "../vault/models/view/cipher.view";
import { SendView } from "../../tools/send/models/view/send.view";
import { IndexedEntityId, UserId } from "../../types/guid";
import { CipherView } from "../models/view/cipher.view";
export abstract class SearchService {
indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>;

View File

@@ -0,0 +1,12 @@
import { UnionOfValues } from "../types/union-of-values";
/**
* Available pages within the extension by their URL.
* Useful when opening a specific page within the popup.
*/
export const ExtensionPageUrls: Record<string, `popup/index.html#/${string}`> = {
Index: "popup/index.html#/",
AtRiskPasswords: "popup/index.html#/at-risk-passwords",
} as const;
export type ExtensionPageUrls = UnionOfValues<typeof ExtensionPageUrls>;

View File

@@ -3,3 +3,4 @@ export * from "./cipher-type";
export * from "./field-type.enum";
export * from "./linked-id-type.enum";
export * from "./secure-note-type.enum";
export * from "./extension-page-urls.enum";

View File

@@ -1,7 +1,9 @@
const VaultMessages = {
HasBwInstalled: "hasBwInstalled",
checkBwInstalled: "checkIfBWExtensionInstalled",
/** @deprecated use {@link OpenBrowserExtensionToUrl} */
OpenAtRiskPasswords: "openAtRiskPasswords",
OpenBrowserExtensionToUrl: "openBrowserExtensionToUrl",
PopupOpened: "popupOpened",
} as const;

View File

@@ -353,14 +353,14 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
type: this.type,
favorite: this.favorite ?? false,
organizationUseTotp: this.organizationUseTotp ?? false,
edit: this.edit,
edit: this.edit ?? true,
permissions: this.permissions
? {
delete: this.permissions.delete,
restore: this.permissions.restore,
}
: undefined,
viewPassword: this.viewPassword,
viewPassword: this.viewPassword ?? true,
localData: this.localData
? {
lastUsedDate: this.localData.lastUsedDate

View File

@@ -97,8 +97,8 @@ export class LoginUri extends Domain {
*/
toSdkLoginUri(): SdkLoginUri {
return {
uri: this.uri.toJSON(),
uriChecksum: this.uriChecksum.toJSON(),
uri: this.uri?.toJSON(),
uriChecksum: this.uriChecksum?.toJSON(),
match: this.match,
};
}

View File

@@ -36,24 +36,6 @@ describe("serviceUtils", () => {
});
});
describe("nestedTraverse_vNext", () => {
it("should traverse a tree and add a node at the correct position given a valid path", () => {
const nodeToBeAdded: FakeObject = { id: "1.2.1", name: "1.2.1" };
const path = ["1", "1.2", "1.2.1"];
ServiceUtils.nestedTraverse_vNext(nodeTree, 0, path, nodeToBeAdded, null, "/");
expect(nodeTree[0].children[1].children[0].node).toEqual(nodeToBeAdded);
});
it("should combine the path for missing nodes and use as the added node name given an invalid path", () => {
const nodeToBeAdded: FakeObject = { id: "blank", name: "blank" };
const path = ["3", "3.1", "3.1.1"];
ServiceUtils.nestedTraverse_vNext(nodeTree, 0, path, nodeToBeAdded, null, "/");
expect(nodeTree[2].children[0].node.name).toEqual("3.1/3.1.1");
});
});
describe("getTreeNodeObject", () => {
it("should return a matching node given a single tree branch and a valid id", () => {
const id = "1.1.1";

View File

@@ -4,64 +4,6 @@
import { ITreeNodeObject, TreeNode } from "./models/domain/tree-node";
export class ServiceUtils {
static nestedTraverse(
nodeTree: TreeNode<ITreeNodeObject>[],
partIndex: number,
parts: string[],
obj: ITreeNodeObject,
parent: TreeNode<ITreeNodeObject> | undefined,
delimiter: string,
) {
if (parts.length <= partIndex) {
return;
}
const end: boolean = partIndex === parts.length - 1;
const partName: string = parts[partIndex];
for (let i = 0; i < nodeTree.length; i++) {
if (nodeTree[i].node.name !== partName) {
continue;
}
if (end && nodeTree[i].node.id !== obj.id) {
// Another node exists with the same name as the node being added
nodeTree.push(new TreeNode(obj, parent, partName));
return;
}
// Move down the tree to the next level
ServiceUtils.nestedTraverse(
nodeTree[i].children,
partIndex + 1,
parts,
obj,
nodeTree[i],
delimiter,
);
return;
}
// If there's no node here with the same name...
if (nodeTree.filter((n) => n.node.name === partName).length === 0) {
// And we're at the end of the path given, add the node
if (end) {
nodeTree.push(new TreeNode(obj, parent, partName));
return;
}
// And we're not at the end of the path, combine the current name with the next name
// 1, *1.2, 1.2.1 becomes
// 1, *1.2/1.2.1
const newPartName = partName + delimiter + parts[partIndex + 1];
ServiceUtils.nestedTraverse(
nodeTree,
0,
[newPartName, ...parts.slice(partIndex + 2)],
obj,
parent,
delimiter,
);
}
}
/**
* Recursively adds a node to nodeTree
* @param {TreeNode<ITreeNodeObject>[]} nodeTree - An array of TreeNodes that the node will be added to
@@ -71,7 +13,7 @@ export class ServiceUtils {
* @param {ITreeNodeObject} parent - The parent node of the `obj` node
* @param {string} delimiter - The delimiter used to split the path string, will be used to combine the path for missing nodes
*/
static nestedTraverse_vNext(
static nestedTraverse(
nodeTree: TreeNode<ITreeNodeObject>[],
partIndex: number,
parts: string[],
@@ -104,7 +46,7 @@ export class ServiceUtils {
// 1, *1.2, 1.2.1 becomes
// 1, *1.2/1.2.1
const newPartName = partName + delimiter + parts[partIndex + 1];
ServiceUtils.nestedTraverse_vNext(
ServiceUtils.nestedTraverse(
nodeTree,
0,
[newPartName, ...parts.slice(partIndex + 2)],
@@ -114,7 +56,7 @@ export class ServiceUtils {
);
} else {
// There is a node here with the same name, descend into it
ServiceUtils.nestedTraverse_vNext(
ServiceUtils.nestedTraverse(
matchingNodes[0].children,
partIndex + 1,
parts,

View File

@@ -10,7 +10,6 @@ import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-a
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { makeStaticByteArray, makeSymmetricCryptoKey } from "../../../spec/utils";
import { ApiService } from "../../abstractions/api.service";
import { SearchService } from "../../abstractions/search.service";
import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { BulkEncryptService } from "../../key-management/crypto/abstractions/bulk-encrypt.service";
@@ -29,6 +28,7 @@ import { CipherKey, OrgKey, UserKey } from "../../types/key";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { EncryptionContext } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { SearchService } from "../abstractions/search.service";
import { FieldType } from "../enums";
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
import { CipherType } from "../enums/cipher-type";

View File

@@ -9,7 +9,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { KeyService } from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
import { SearchService } from "../../abstractions/search.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
@@ -38,6 +37,7 @@ import {
EncryptionContext,
} from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { SearchService } from "../abstractions/search.service";
import { FieldType } from "../enums";
import { CipherType } from "../enums/cipher-type";
import { CipherData } from "../models/data/cipher.data";

View File

@@ -4,21 +4,21 @@ import * as lunr from "lunr";
import { Observable, firstValueFrom, map } from "rxjs";
import { Jsonify } from "type-fest";
import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service";
import { UriMatchStrategy } from "../models/domain/domain-service";
import { I18nService } from "../platform/abstractions/i18n.service";
import { LogService } from "../platform/abstractions/log.service";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { LogService } from "../../platform/abstractions/log.service";
import {
SingleUserState,
StateProvider,
UserKeyDefinition,
VAULT_SEARCH_MEMORY,
} from "../platform/state";
import { SendView } from "../tools/send/models/view/send.view";
import { IndexedEntityId, UserId } from "../types/guid";
import { FieldType } from "../vault/enums";
import { CipherType } from "../vault/enums/cipher-type";
import { CipherView } from "../vault/models/view/cipher.view";
} from "../../platform/state";
import { SendView } from "../../tools/send/models/view/send.view";
import { IndexedEntityId, UserId } from "../../types/guid";
import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service";
import { FieldType } from "../enums";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
export type SerializedLunrIndex = {
version: string;