1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-17635] [PM-18601] Simplifying mocking and usage of the sdk (#14287)

* feat: add our own custom deep mocker

* feat: use new mock service in totp tests

* feat: implement userClient mocking

* chore: move mock files

* feat: replace existing manual sdkService mocking

* chore: rename to 'client'

* chore: improve docs

* feat: refactor sdkService to never return undefined BitwardenClient
This commit is contained in:
Andreas Coroiu
2025-05-28 15:00:30 +02:00
committed by GitHub
parent 4fcc4793bb
commit d1fb37d696
9 changed files with 444 additions and 54 deletions

View File

@@ -53,15 +53,18 @@ export abstract class SdkService {
* This client can be used for operations that require a user context, such as retrieving ciphers
* and operations involving crypto. It can also be used for operations that don't require a user context.
*
* - If the user is not logged when the subscription is created, the observable will complete
* immediately with {@link UserNotLoggedInError}.
* - If the user is logged in, the observable will emit the client and complete whithout an error
* when the user logs out.
*
* **WARNING:** Do not use `firstValueFrom(userClient$)`! Any operations on the client must be done within the observable.
* The client will be destroyed when the observable is no longer subscribed to.
* Please let platform know if you need a client that is not destroyed when the observable is no longer subscribed to.
*
* @param userId The user id for which to retrieve the client
*
* @throws {UserNotLoggedInError} If the user is not logged in
*/
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined>;
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient>>;
/**
* This method is used during/after an authentication procedure to set a new client for a specific user.

View File

@@ -132,15 +132,13 @@ describe("DefaultSdkService", () => {
);
keyService.userKey$.calledWith(userId).mockReturnValue(userKey$);
const subject = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
service.userClient$(userId).subscribe(subject);
await new Promise(process.nextTick);
const userClientTracker = new ObservableTracker(service.userClient$(userId), false);
await userClientTracker.pauseUntilReceived(1);
userKey$.next(undefined);
await new Promise(process.nextTick);
await userClientTracker.expectCompletion();
expect(mockClient.free).toHaveBeenCalledTimes(1);
expect(subject.value).toBe(undefined);
});
});

View File

@@ -71,7 +71,7 @@ export class DefaultSdkService implements SdkService {
private userAgent: string | null = null,
) {}
userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined> {
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
return this.sdkClientOverrides.pipe(
takeWhile((clients) => clients[userId] !== UnsetClient, false),
map((clients) => {
@@ -88,6 +88,7 @@ export class DefaultSdkService implements SdkService {
return this.internalClient$(userId);
}),
takeWhile((client) => client !== undefined, false),
throwIfEmpty(() => new UserNotLoggedInError(userId)),
);
}
@@ -112,7 +113,7 @@ export class DefaultSdkService implements SdkService {
* @param userId The user id for which to create the client
* @returns An observable that emits the client for the user
*/
private internalClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined> {
private internalClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
const cached = this.sdkClientCache.get(userId);
if (cached !== undefined) {
return cached;

View File

@@ -0,0 +1,58 @@
import { mockDeep } from "./mock-deep";
class ToBeMocked {
property = "value";
method() {
return "method";
}
sub() {
return new SubToBeMocked();
}
}
class SubToBeMocked {
subProperty = "subValue";
sub() {
return new SubSubToBeMocked();
}
}
class SubSubToBeMocked {
subSubProperty = "subSubValue";
}
describe("deepMock", () => {
it("can mock properties", () => {
const mock = mockDeep<ToBeMocked>();
mock.property.replaceProperty("mocked value");
expect(mock.property).toBe("mocked value");
});
it("can mock methods", () => {
const mock = mockDeep<ToBeMocked>();
mock.method.mockReturnValue("mocked method");
expect(mock.method()).toBe("mocked method");
});
it("can mock sub-properties", () => {
const mock = mockDeep<ToBeMocked>();
mock.sub.mockDeep().subProperty.replaceProperty("mocked sub value");
expect(mock.sub().subProperty).toBe("mocked sub value");
});
it("can mock sub-sub-properties", () => {
const mock = mockDeep<ToBeMocked>();
mock.sub.mockDeep().sub.mockDeep().subSubProperty.replaceProperty("mocked sub-sub value");
expect(mock.sub().sub().subSubProperty).toBe("mocked sub-sub value");
});
it("returns the same mock object when calling mockDeep multiple times", () => {
const mock = mockDeep<ToBeMocked>();
const subMock1 = mock.sub.mockDeep();
const subMock2 = mock.sub.mockDeep();
expect(subMock1).toBe(subMock2);
});
});

View File

@@ -0,0 +1,271 @@
// This is a modification of the code found in https://github.com/marchaos/jest-mock-extended
// to better support deep mocking of objects.
// MIT License
// Copyright (c) 2019 Marc McIntyre
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { jest } from "@jest/globals";
import { FunctionLike } from "jest-mock";
import { calledWithFn, MatchersOrLiterals } from "jest-mock-extended";
import { PartialDeep } from "type-fest";
type ProxiedProperty = string | number | symbol;
export interface GlobalConfig {
// ignoreProps is required when we don't want to return anything for a mock (for example, when mocking a promise).
ignoreProps?: ProxiedProperty[];
}
const DEFAULT_CONFIG: GlobalConfig = {
ignoreProps: ["then"],
};
let GLOBAL_CONFIG = DEFAULT_CONFIG;
export const JestMockExtended = {
DEFAULT_CONFIG,
configure: (config: GlobalConfig) => {
// Shallow merge so they can override anything they want.
GLOBAL_CONFIG = { ...DEFAULT_CONFIG, ...config };
},
resetConfig: () => {
GLOBAL_CONFIG = DEFAULT_CONFIG;
},
};
export interface CalledWithMock<T extends FunctionLike> extends jest.Mock<T> {
calledWith: (...args: [...MatchersOrLiterals<Parameters<T>>]) => jest.Mock<T>;
}
export interface MockDeepMock<R> {
mockDeep: () => DeepMockProxy<R>;
}
export interface ReplaceProperty<T> {
/**
* mockDeep will by default return a jest.fn() for all properties,
* but this allows you to replace the property with a value.
* @param value The value to replace the property with.
*/
replaceProperty(value: T): void;
}
export type _MockProxy<T> = {
[K in keyof T]: T[K] extends FunctionLike ? T[K] & CalledWithMock<T[K]> : T[K];
};
export type MockProxy<T> = _MockProxy<T> & T;
export type _DeepMockProxy<T> = {
// This supports deep mocks in the else branch
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? T[K] & CalledWithMock<T[K]> & MockDeepMock<R>
: T[K] & ReplaceProperty<T[K]> & _DeepMockProxy<T[K]>;
};
// we intersect with T here instead of on the mapped type above to
// prevent immediate type resolution on a recursive type, this will
// help to improve performance for deeply nested recursive mocking
// at the same time, this intersection preserves private properties
export type DeepMockProxy<T> = _DeepMockProxy<T> & T;
export type _DeepMockProxyWithFuncPropSupport<T> = {
// This supports deep mocks in the else branch
[K in keyof T]: T[K] extends FunctionLike
? CalledWithMock<T[K]> & DeepMockProxy<T[K]>
: DeepMockProxy<T[K]>;
};
export type DeepMockProxyWithFuncPropSupport<T> = _DeepMockProxyWithFuncPropSupport<T> & T;
export interface MockOpts {
deep?: boolean;
fallbackMockImplementation?: (...args: any[]) => any;
}
export const mockClear = (mock: MockProxy<any>) => {
for (const key of Object.keys(mock)) {
if (mock[key] === null || mock[key] === undefined) {
continue;
}
if (mock[key]._isMockObject) {
mockClear(mock[key]);
}
if (mock[key]._isMockFunction) {
mock[key].mockClear();
}
}
// This is a catch for if they pass in a jest.fn()
if (!mock._isMockObject) {
return mock.mockClear();
}
};
export const mockReset = (mock: MockProxy<any>) => {
for (const key of Object.keys(mock)) {
if (mock[key] === null || mock[key] === undefined) {
continue;
}
if (mock[key]._isMockObject) {
mockReset(mock[key]);
}
if (mock[key]._isMockFunction) {
mock[key].mockReset();
}
}
// This is a catch for if they pass in a jest.fn()
// Worst case, we will create a jest.fn() (since this is a proxy)
// below in the get and call mockReset on it
if (!mock._isMockObject) {
return mock.mockReset();
}
};
export function mockDeep<T>(
opts: {
funcPropSupport?: true;
fallbackMockImplementation?: MockOpts["fallbackMockImplementation"];
},
mockImplementation?: PartialDeep<T>,
): DeepMockProxyWithFuncPropSupport<T>;
export function mockDeep<T>(mockImplementation?: PartialDeep<T>): DeepMockProxy<T>;
export function mockDeep(arg1: any, arg2?: any) {
const [opts, mockImplementation] =
typeof arg1 === "object" &&
(typeof arg1.fallbackMockImplementation === "function" || arg1.funcPropSupport === true)
? [arg1, arg2]
: [{}, arg1];
return mock(mockImplementation, {
deep: true,
fallbackMockImplementation: opts.fallbackMockImplementation,
});
}
const overrideMockImp = (obj: PartialDeep<any>, opts?: MockOpts) => {
const proxy = new Proxy<MockProxy<any>>(obj, handler(opts));
for (const name of Object.keys(obj)) {
if (typeof obj[name] === "object" && obj[name] !== null) {
proxy[name] = overrideMockImp(obj[name], opts);
} else {
proxy[name] = obj[name];
}
}
return proxy;
};
const handler = (opts?: MockOpts): ProxyHandler<any> => ({
ownKeys(target: MockProxy<any>) {
return Reflect.ownKeys(target);
},
set: (obj: MockProxy<any>, property: ProxiedProperty, value: any) => {
obj[property] = value;
return true;
},
get: (obj: MockProxy<any>, property: ProxiedProperty) => {
const fn = calledWithFn({ fallbackMockImplementation: opts?.fallbackMockImplementation });
if (!(property in obj)) {
if (GLOBAL_CONFIG.ignoreProps?.includes(property)) {
return undefined;
}
// Jest's internal equality checking does some wierd stuff to check for iterable equality
if (property === Symbol.iterator) {
return obj[property];
}
if (property === "_deepMock") {
return obj[property];
}
// So this calls check here is totally not ideal - jest internally does a
// check to see if this is a spy - which we want to say no to, but blindly returning
// an proxy for calls results in the spy check returning true. This is another reason
// why deep is opt in.
if (opts?.deep && property !== "calls") {
obj[property] = new Proxy<MockProxy<any>>(fn, handler(opts));
obj[property].replaceProperty = <T extends typeof obj, K extends keyof T>(value: T[K]) => {
obj[property] = value;
};
obj[property].mockDeep = () => {
if (obj[property]._deepMock) {
return obj[property]._deepMock;
}
const mock = mockDeep({
fallbackMockImplementation: opts?.fallbackMockImplementation,
});
(obj[property] as CalledWithMock<any>).mockReturnValue(mock);
obj[property]._deepMock = mock;
return mock;
};
obj[property]._isMockObject = true;
} else {
obj[property] = calledWithFn({
fallbackMockImplementation: opts?.fallbackMockImplementation,
});
}
}
// @ts-expect-error Hack by author of jest-mock-extended
if (obj instanceof Date && typeof obj[property] === "function") {
// @ts-expect-error Hack by author of jest-mock-extended
return obj[property].bind(obj);
}
return obj[property];
},
});
const mock = <T, MockedReturn extends MockProxy<T> & T = MockProxy<T> & T>(
mockImplementation: PartialDeep<T> = {} as PartialDeep<T>,
opts?: MockOpts,
): MockedReturn => {
// @ts-expect-error private
mockImplementation!._isMockObject = true;
return overrideMockImp(mockImplementation, opts);
};
export const mockFn = <T extends FunctionLike>(): CalledWithMock<T> & T => {
// @ts-expect-error Hack by author of jest-mock-extended
return calledWithFn();
};
export const stub = <T extends object>(): T => {
return new Proxy<T>({} as T, {
get: (obj, property: ProxiedProperty) => {
if (property in obj) {
// @ts-expect-error Hack by author of jest-mock-extended
return obj[property];
}
return jest.fn();
},
});
};
export default mock;

View File

@@ -0,0 +1,81 @@
import {
BehaviorSubject,
distinctUntilChanged,
map,
Observable,
takeWhile,
throwIfEmpty,
} from "rxjs";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { UserId } from "../../types/guid";
import { SdkService, UserNotLoggedInError } from "../abstractions/sdk/sdk.service";
import { Rc } from "../misc/reference-counting/rc";
import { DeepMockProxy, mockDeep } from "./mock-deep";
export class MockSdkService implements SdkService {
private userClients$ = new BehaviorSubject<{
[userId: UserId]: Rc<BitwardenClient> | undefined;
}>({});
private _client$ = new BehaviorSubject(mockDeep<BitwardenClient>());
client$ = this._client$.asObservable();
version$ = new BehaviorSubject("0.0.1-test").asObservable();
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
return this.userClients$.pipe(
takeWhile((clients) => clients[userId] !== undefined, false),
map((clients) => clients[userId] as Rc<BitwardenClient>),
distinctUntilChanged(),
throwIfEmpty(() => new UserNotLoggedInError(userId)),
);
}
setClient(): void {
throw new Error("Not supported in mock service");
}
/**
* Returns the non-user scoped client mock.
* This is what is returned by the `client$` observable.
*/
get client(): DeepMockProxy<BitwardenClient> {
return this._client$.value;
}
readonly simulate = {
/**
* Simulates a user login, and returns a user-scoped mock for the user.
* This will be return by the `userClient$` observable.
*
* @param userId The userId to simulate login for.
* @returns A user-scoped mock for the user.
*/
userLogin: (userId: UserId) => {
const client = mockDeep<BitwardenClient>();
this.userClients$.next({
...this.userClients$.getValue(),
[userId]: new Rc(client),
});
return client;
},
/**
* Simulates a user logout, and disposes the user-scoped mock for the user.
* This will remove the user-scoped mock from the `userClient$` observable.
*
* @param userId The userId to simulate logout for.
*/
userLogout: (userId: UserId) => {
const clients = this.userClients$.value;
clients[userId]?.markForDisposal();
this.userClients$.next({
...clients,
[userId]: undefined,
});
},
};
}

View File

@@ -1,38 +1,27 @@
import { mock } from "jest-mock-extended";
import { of, take } from "rxjs";
import { take } from "rxjs";
import { BitwardenClient, TotpResponse } from "@bitwarden/sdk-internal";
import { TotpResponse } from "@bitwarden/sdk-internal";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
import { TotpService } from "./totp.service";
describe("TotpService", () => {
let totpService: TotpService;
let generateTotpMock: jest.Mock;
const sdkService = mock<SdkService>();
let totpService!: TotpService;
let sdkService!: MockSdkService;
beforeEach(() => {
generateTotpMock = jest
.fn()
.mockReturnValueOnce({
sdkService = new MockSdkService();
sdkService.client.vault
.mockDeep()
.totp.mockDeep()
.generate_totp.mockReturnValueOnce({
code: "123456",
period: 30,
})
.mockReturnValueOnce({ code: "654321", period: 30 })
.mockReturnValueOnce({ code: "567892", period: 30 });
const mockBitwardenClient = {
vault: () => ({
totp: () => ({
generate_totp: generateTotpMock,
}),
}),
};
sdkService.client$ = of(mockBitwardenClient as unknown as BitwardenClient);
totpService = new TotpService(sdkService);
// TOTP is time-based, so we need to mock the current time

View File

@@ -1,5 +1,4 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
// 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
@@ -8,14 +7,13 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { KeyService } from "@bitwarden/key-management";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer";
import { Importer } from "../importers/importer";
@@ -35,7 +33,7 @@ describe("ImportService", () => {
let encryptService: MockProxy<EncryptService>;
let pinService: MockProxy<PinServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let sdkService: MockProxy<SdkService>;
let sdkService: MockSdkService;
beforeEach(() => {
cipherService = mock<CipherService>();
@@ -46,9 +44,7 @@ describe("ImportService", () => {
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
pinService = mock<PinServiceAbstraction>();
const mockClient = mock<BitwardenClient>();
sdkService = mock<SdkService>();
sdkService.client$ = of(mockClient, mockClient, mockClient);
sdkService = new MockSdkService();
importService = new ImportService(
cipherService,

View File

@@ -5,17 +5,17 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service";
import { makeStaticByteArray, mockEnc } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { BitwardenClient, VerifyAsymmetricKeysResponse } from "@bitwarden/sdk-internal";
import { VerifyAsymmetricKeysResponse } from "@bitwarden/sdk-internal";
import { KeyService } from "../../abstractions/key.service";
import { UserAsymmetricKeysRegenerationApiService } from "../abstractions/user-asymmetric-key-regeneration-api.service";
@@ -24,24 +24,17 @@ import { DefaultUserAsymmetricKeysRegenerationService } from "./default-user-asy
function setupVerificationResponse(
mockVerificationResponse: VerifyAsymmetricKeysResponse,
sdkService: MockProxy<SdkService>,
sdkService: MockSdkService,
) {
const mockKeyPairResponse = {
userPublicKey: "userPublicKey",
userKeyEncryptedPrivateKey: "userKeyEncryptedPrivateKey",
};
sdkService.client$ = of({
crypto: () => ({
verify_asymmetric_keys: jest.fn().mockReturnValue(mockVerificationResponse),
make_key_pair: jest.fn().mockReturnValue(mockKeyPairResponse),
}),
free: jest.fn(),
echo: jest.fn(),
version: jest.fn(),
throw: jest.fn(),
catch: jest.fn(),
} as unknown as BitwardenClient);
sdkService.client.crypto
.mockDeep()
.verify_asymmetric_keys.mockReturnValue(mockVerificationResponse);
sdkService.client.crypto.mockDeep().make_key_pair.mockReturnValue(mockKeyPairResponse);
}
function setupUserKeyValidation(
@@ -74,7 +67,7 @@ describe("regenerateIfNeeded", () => {
let cipherService: MockProxy<CipherService>;
let userAsymmetricKeysRegenerationApiService: MockProxy<UserAsymmetricKeysRegenerationApiService>;
let logService: MockProxy<LogService>;
let sdkService: MockProxy<SdkService>;
let sdkService: MockSdkService;
let apiService: MockProxy<ApiService>;
let configService: MockProxy<ConfigService>;
let encryptService: MockProxy<EncryptService>;
@@ -84,7 +77,7 @@ describe("regenerateIfNeeded", () => {
cipherService = mock<CipherService>();
userAsymmetricKeysRegenerationApiService = mock<UserAsymmetricKeysRegenerationApiService>();
logService = mock<LogService>();
sdkService = mock<SdkService>();
sdkService = new MockSdkService();
apiService = mock<ApiService>();
configService = mock<ConfigService>();
encryptService = mock<EncryptService>();