1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 20:24:01 +00:00

Merge remote-tracking branch 'origin/main' into rename-tsconfig

This commit is contained in:
addisonbeck
2025-05-29 15:10:05 -04:00
32 changed files with 708 additions and 382 deletions

View File

@@ -413,6 +413,12 @@
allowedVersions: "1.0.0",
description: "Higher versions of lowdb are not compatible with CommonJS",
},
{
// Pin types as well since we are not upgrading past v1 (and also v2+ does not need separate types).
matchPackageNames: ["@types/lowdb"],
allowedVersions: "< 2.0.0",
description: "Higher versions of lowdb do not need separate types",
},
],
ignoreDeps: ["@types/koa-bodyparser", "bootstrap", "node-ipc", "@bitwarden/sdk-internal"],
}

View File

@@ -1579,252 +1579,6 @@ export default class AutofillService implements AutofillServiceInterface {
return [expectedDateFormat, dateFormatPatterns];
}
/**
* Generates the autofill script for the specified page details and identify cipher item.
* @param {AutofillScript} fillScript
* @param {AutofillPageDetails} pageDetails
* @param {{[p: string]: AutofillField}} filledFields
* @param {GenerateFillScriptOptions} options
* @returns {AutofillScript}
* @private
*/
private async generateIdentityFillScript(
fillScript: AutofillScript,
pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField },
options: GenerateFillScriptOptions,
): Promise<AutofillScript> {
if (await this.configService.getFeatureFlag(FeatureFlag.GenerateIdentityFillScriptRefactor)) {
return this._generateIdentityFillScript(fillScript, pageDetails, filledFields, options);
}
if (!options.cipher.identity) {
return null;
}
const fillFields: { [id: string]: AutofillField } = {};
pageDetails.fields.forEach((f) => {
if (
AutofillService.isExcludedFieldType(f, AutoFillConstants.ExcludedAutofillTypes) ||
["current-password", "new-password"].includes(f.autoCompleteType)
) {
return;
}
for (let i = 0; i < IdentityAutoFillConstants.IdentityAttributes.length; i++) {
const attr = IdentityAutoFillConstants.IdentityAttributes[i];
// eslint-disable-next-line
if (!f.hasOwnProperty(attr) || !f[attr] || !f.viewable) {
continue;
}
// ref https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill
// ref https://developers.google.com/web/fundamentals/design-and-ux/input/forms/
if (
!fillFields.name &&
AutofillService.isFieldMatch(
f[attr],
IdentityAutoFillConstants.FullNameFieldNames,
IdentityAutoFillConstants.FullNameFieldNameValues,
)
) {
fillFields.name = f;
break;
} else if (
!fillFields.firstName &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.FirstnameFieldNames)
) {
fillFields.firstName = f;
break;
} else if (
!fillFields.middleName &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.MiddlenameFieldNames)
) {
fillFields.middleName = f;
break;
} else if (
!fillFields.lastName &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.LastnameFieldNames)
) {
fillFields.lastName = f;
break;
} else if (
!fillFields.title &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.TitleFieldNames)
) {
fillFields.title = f;
break;
} else if (
!fillFields.email &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.EmailFieldNames)
) {
fillFields.email = f;
break;
} else if (
!fillFields.address1 &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address1FieldNames)
) {
fillFields.address1 = f;
break;
} else if (
!fillFields.address2 &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address2FieldNames)
) {
fillFields.address2 = f;
break;
} else if (
!fillFields.address3 &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address3FieldNames)
) {
fillFields.address3 = f;
break;
} else if (
!fillFields.address &&
AutofillService.isFieldMatch(
f[attr],
IdentityAutoFillConstants.AddressFieldNames,
IdentityAutoFillConstants.AddressFieldNameValues,
)
) {
fillFields.address = f;
break;
} else if (
!fillFields.postalCode &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.PostalCodeFieldNames)
) {
fillFields.postalCode = f;
break;
} else if (
!fillFields.city &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CityFieldNames)
) {
fillFields.city = f;
break;
} else if (
!fillFields.state &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.StateFieldNames)
) {
fillFields.state = f;
break;
} else if (
!fillFields.country &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CountryFieldNames)
) {
fillFields.country = f;
break;
} else if (
!fillFields.phone &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.PhoneFieldNames)
) {
fillFields.phone = f;
break;
} else if (
!fillFields.username &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.UserNameFieldNames)
) {
fillFields.username = f;
break;
} else if (
!fillFields.company &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CompanyFieldNames)
) {
fillFields.company = f;
break;
}
}
});
const identity = options.cipher.identity;
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "title");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "firstName");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "middleName");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "lastName");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address1");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address2");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address3");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "city");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "postalCode");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "company");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "email");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "phone");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "username");
let filledState = false;
if (fillFields.state && identity.state && identity.state.length > 2) {
const stateLower = identity.state.toLowerCase();
const isoState =
IdentityAutoFillConstants.IsoStates[stateLower] ||
IdentityAutoFillConstants.IsoProvinces[stateLower];
if (isoState) {
filledState = true;
this.makeScriptActionWithValue(fillScript, isoState, fillFields.state, filledFields);
}
}
if (!filledState) {
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "state");
}
let filledCountry = false;
if (fillFields.country && identity.country && identity.country.length > 2) {
const countryLower = identity.country.toLowerCase();
const isoCountry = IdentityAutoFillConstants.IsoCountries[countryLower];
if (isoCountry) {
filledCountry = true;
this.makeScriptActionWithValue(fillScript, isoCountry, fillFields.country, filledFields);
}
}
if (!filledCountry) {
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "country");
}
if (fillFields.name && (identity.firstName || identity.lastName)) {
let fullName = "";
if (AutofillService.hasValue(identity.firstName)) {
fullName = identity.firstName;
}
if (AutofillService.hasValue(identity.middleName)) {
if (fullName !== "") {
fullName += " ";
}
fullName += identity.middleName;
}
if (AutofillService.hasValue(identity.lastName)) {
if (fullName !== "") {
fullName += " ";
}
fullName += identity.lastName;
}
this.makeScriptActionWithValue(fillScript, fullName, fillFields.name, filledFields);
}
if (fillFields.address && AutofillService.hasValue(identity.address1)) {
let address = "";
if (AutofillService.hasValue(identity.address1)) {
address = identity.address1;
}
if (AutofillService.hasValue(identity.address2)) {
if (address !== "") {
address += ", ";
}
address += identity.address2;
}
if (AutofillService.hasValue(identity.address3)) {
if (address !== "") {
address += ", ";
}
address += identity.address3;
}
this.makeScriptActionWithValue(fillScript, address, fillFields.address, filledFields);
}
return fillScript;
}
/**
* Generates the autofill script for the specified page details and identity cipher item.
*
@@ -1833,7 +1587,7 @@ export default class AutofillService implements AutofillServiceInterface {
* @param filledFields - The fields that have already been filled, passed between method references
* @param options - Contains data used to fill cipher items
*/
private _generateIdentityFillScript(
private generateIdentityFillScript(
fillScript: AutofillScript,
pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField },

View File

@@ -7,13 +7,13 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import MainBackground from "../../background/main.background";
import IconDetails from "../../vault/background/models/icon-details";
import { BrowserApi } from "../browser/browser-api";
import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service";
export type BadgeOptions = {
tab?: chrome.tabs.Tab;
@@ -28,6 +28,7 @@ export class UpdateBadge {
private badgeAction: typeof chrome.action | typeof chrome.browserAction;
private sidebarAction: OperaSidebarAction | FirefoxSidebarAction;
private win: Window & typeof globalThis;
private platformUtilsService: PlatformUtilsService;
constructor(win: Window & typeof globalThis, services: MainBackground) {
this.badgeAction = BrowserApi.getBrowserAction();
@@ -38,6 +39,7 @@ export class UpdateBadge {
this.authService = services.authService;
this.cipherService = services.cipherService;
this.accountService = services.accountService;
this.platformUtilsService = services.platformUtilsService;
}
async run(opts?: { tabId?: number; windowId?: number }): Promise<void> {
@@ -129,7 +131,7 @@ export class UpdateBadge {
38: "/images/icon38" + iconSuffix + ".png",
},
};
if (windowId && BrowserPlatformUtilsService.isFirefox()) {
if (windowId && this.platformUtilsService.isFirefox()) {
options.windowId = windowId;
}
@@ -204,9 +206,7 @@ export class UpdateBadge {
}
private get useSyncApiCalls() {
return (
BrowserPlatformUtilsService.isFirefox() || BrowserPlatformUtilsService.isSafari(this.win)
);
return this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari();
}
private isOperaSidebar(

View File

@@ -126,12 +126,11 @@ describe("Browser Utils Service", () => {
configurable: true,
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0",
});
jest.spyOn(BrowserPlatformUtilsService, "isFirefox");
browserPlatformUtilsService.getDevice();
expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.FirefoxExtension);
expect(BrowserPlatformUtilsService.isFirefox).toHaveBeenCalledTimes(1);
expect(browserPlatformUtilsService.isFirefox()).toBe(true);
});
});

View File

@@ -60,10 +60,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return ClientType.Browser;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
static isFirefox(): boolean {
private static isFirefox(): boolean {
return (
navigator.userAgent.indexOf(" Firefox/") !== -1 ||
navigator.userAgent.indexOf(" Gecko/") !== -1
@@ -74,9 +71,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.FirefoxExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
private static isChrome(globalContext: Window | ServiceWorkerGlobalScope): boolean {
return globalContext.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1;
}
@@ -85,9 +79,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.ChromeExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
private static isEdge(): boolean {
return navigator.userAgent.indexOf(" Edg/") !== -1;
}
@@ -96,9 +87,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.EdgeExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
private static isOpera(globalContext: Window | ServiceWorkerGlobalScope): boolean {
return (
!!globalContext.opr?.addons ||
@@ -111,9 +99,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.OperaExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
private static isVivaldi(): boolean {
return navigator.userAgent.indexOf(" Vivaldi/") !== -1;
}
@@ -122,10 +107,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.VivaldiExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
static isSafari(globalContext: Window | ServiceWorkerGlobalScope): boolean {
private static isSafari(globalContext: Window | ServiceWorkerGlobalScope): boolean {
// Opera masquerades as Safari, so make sure we're not there first
return (
!BrowserPlatformUtilsService.isOpera(globalContext) &&
@@ -137,6 +119,10 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return navigator.userAgent.match("Version/([0-9.]*)")?.[1];
}
isSafari(): boolean {
return this.getDevice() === DeviceType.SafariExtension;
}
/**
* Safari previous to version 16.1 had a bug which caused artifacts on hover in large extension popups.
* https://bugs.webkit.org/show_bug.cgi?id=218704
@@ -151,10 +137,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return parts?.[0] < 16 || (parts?.[0] === 16 && parts?.[1] === 0);
}
isSafari(): boolean {
return this.getDevice() === DeviceType.SafariExtension;
}
isIE(): boolean {
return false;
}

View File

@@ -1,8 +1,8 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { Component, NgZone } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { Subject, Subscription, debounceTime, filter } from "rxjs";
import { Subject, Subscription, debounceTime, distinctUntilChanged, filter } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchModule } from "@bitwarden/components";
@@ -22,13 +22,16 @@ export class VaultV2SearchComponent {
private searchText$ = new Subject<string>();
constructor(private vaultPopupItemsService: VaultPopupItemsService) {
constructor(
private vaultPopupItemsService: VaultPopupItemsService,
private ngZone: NgZone,
) {
this.subscribeToLatestSearchText();
this.subscribeToApplyFilter();
}
onSearchTextChanged() {
this.vaultPopupItemsService.applyFilter(this.searchText);
this.searchText$.next(this.searchText);
}
subscribeToLatestSearchText(): Subscription {
@@ -44,9 +47,13 @@ export class VaultV2SearchComponent {
subscribeToApplyFilter(): Subscription {
return this.searchText$
.pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed())
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
.subscribe((data) => {
this.vaultPopupItemsService.applyFilter(data);
this.ngZone.runOutsideAngular(() => {
this.ngZone.run(() => {
this.vaultPopupItemsService.applyFilter(data);
});
});
});
}
}

View File

@@ -498,9 +498,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "camino"

View File

@@ -18,7 +18,7 @@ base64 = "=0.22.1"
bindgen = "=0.71.1"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" }
byteorder = "=1.5.0"
bytes = "=1.9.0"
bytes = "=1.10.1"
cbc = "=0.1.2"
core-foundation = "=0.10.0"
dirs = "=6.0.0"

View File

@@ -10,7 +10,7 @@ import { autostart } from "@bitwarden/desktop-napi";
import { Main } from "../main";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { isFlatpak } from "../utils";
import { isFlatpak, isLinux, isSnapStore } from "../utils";
import { MenuUpdateRequest } from "./menu/menu.updater";
@@ -26,8 +26,11 @@ export class MessagingMain {
async init() {
this.scheduleNextSync();
if (process.platform === "linux") {
await this.desktopSettingsService.setOpenAtLogin(fs.existsSync(this.linuxStartupFile()));
if (isLinux()) {
// Flatpak and snap don't have access to or use the startup file. On flatpak, the autostart portal is used
if (!isFlatpak() && !isSnapStore()) {
await this.desktopSettingsService.setOpenAtLogin(fs.existsSync(this.linuxStartupFile()));
}
} else {
const loginSettings = app.getLoginItemSettings();
await this.desktopSettingsService.setOpenAtLogin(loginSettings.openAtLogin);

View File

@@ -188,13 +188,10 @@ export class DuckDuckGoMessageHandlerService {
}
try {
let decryptedResult = await this.encryptService.decryptString(
const decryptedResult = await this.decryptDuckDuckGoEncString(
message.encryptedCommand as EncString,
this.duckduckgoSharedSecret,
);
decryptedResult = this.trimNullCharsFromMessage(decryptedResult);
return JSON.parse(decryptedResult);
} catch {
this.sendResponse({
@@ -237,7 +234,46 @@ export class DuckDuckGoMessageHandlerService {
ipc.platform.nativeMessaging.sendReply(response);
}
// Trim all null bytes padded at the end of messages. This happens with C encryption libraries.
/*
* Bitwarden type 2 (AES256-CBC-HMAC256) uses PKCS7 padding.
* DuckDuckGo does not use PKCS7 padding; and instead fills the last CBC block with null bytes.
* ref: https://github.com/duckduckgo/apple-browsers/blob/04d678b447869c3a640714718a466b36407db8b6/macOS/DuckDuckGo/PasswordManager/Bitwarden/Services/BWEncryption.m#L141
*
* This is incompatible which means the default encryptService cannot be used to decrypt the message,
* a custom EncString decrypt operation is needed.
*
* This function also trims null characters that are a result of the null-padding from the end of the message.
*/
private async decryptDuckDuckGoEncString(
encString: EncString,
key: SymmetricCryptoKey,
): Promise<string> {
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(
encString.data,
encString.iv,
encString.mac,
key,
);
const computedMac = await this.cryptoFunctionService.hmacFast(
fastParams.macData,
fastParams.macKey,
"sha256",
);
const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac);
if (!macsEqual) {
return null;
}
const decryptedPaddedString = await this.cryptoFunctionService.aesDecryptFast({
mode: "cbc",
parameters: fastParams,
});
return this.trimNullCharsFromMessage(decryptedPaddedString);
}
// DuckDuckGo does not use PKCS7 padding, but instead leaves the values as null,
// so null characters need to be trimmed from the end of the message for the last
// CBC-block.
private trimNullCharsFromMessage(message: string): string {
const charNull = 0;
const charRightCurlyBrace = 125;

View File

@@ -37,6 +37,31 @@ export function getNestedCollectionTree(
return nodes;
}
export function getNestedCollectionTree_vNext(
collections: (CollectionView | CollectionAdminView)[],
): TreeNode<CollectionView | CollectionAdminView>[] {
if (!collections) {
return [];
}
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
// modifies the names of collections.
// These changes risk affecting collections store in StateService.
const clonedCollections = collections
.sort((a, b) => a.name.localeCompare(b.name))
.map(cloneCollection);
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
clonedCollections.forEach((collection) => {
const parts =
collection.name != null
? collection.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter)
: [];
ServiceUtils.nestedTraverse_vNext(nodes, 0, parts, collection, null, NestingDelimiter);
});
return nodes;
}
export function getFlatCollectionTree(
nodes: TreeNode<CollectionAdminView>[],
): CollectionAdminView[];

View File

@@ -125,7 +125,11 @@ import {
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
import { getNestedCollectionTree, getFlatCollectionTree } from "./utils";
import {
getNestedCollectionTree,
getFlatCollectionTree,
getNestedCollectionTree_vNext,
} from "./utils";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
@@ -420,9 +424,16 @@ export class VaultComponent implements OnInit, OnDestroy {
}),
);
const nestedCollections$ = allCollections$.pipe(
map((collections) => getNestedCollectionTree(collections)),
shareReplay({ refCount: true, bufferSize: 1 }),
const nestedCollections$ = combineLatest([
this.allCollectionsWithoutUnassigned$,
this.configService.getFeatureFlag$(FeatureFlag.OptimizeNestedTraverseTypescript),
]).pipe(
map(
([collections, shouldOptimize]) =>
(shouldOptimize
? getNestedCollectionTree_vNext(collections)
: getNestedCollectionTree(collections)) as TreeNode<CollectionAdminView>[],
),
);
const collections$ = combineLatest([

View File

@@ -110,8 +110,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected rowHeight = 69;
protected rowHeightClass = `tw-h-[69px]`;
private organizationUsersCount = 0;
get occupiedSeatCount(): number {
return this.dataSource.activeUserCount;
return this.organizationUsersCount;
}
constructor(
@@ -218,6 +220,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
);
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
this.organizationUsersCount = billingMetadata.organizationOccupiedSeats;
await this.load();

View File

@@ -1,6 +1,4 @@
<div class="tabbed-header">
<h1>{{ "changeMasterPassword" | i18n }}</h1>
</div>
<h1 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "changeMasterPassword" | i18n }}</h1>
<div class="tw-max-w-lg tw-mb-12">
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>

View File

@@ -49,7 +49,9 @@ import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -82,6 +84,7 @@ import {
import {
getNestedCollectionTree,
getFlatCollectionTree,
getNestedCollectionTree_vNext,
} from "../../admin-console/organizations/collections";
import {
CollectionDialogAction,
@@ -270,6 +273,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private trialFlowService: TrialFlowService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private billingNotificationService: BillingNotificationService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -326,8 +330,15 @@ export class VaultComponent implements OnInit, OnDestroy {
const filter$ = this.routedVaultFilterService.filter$;
const allCollections$ = this.collectionService.decryptedCollections$;
const nestedCollections$ = allCollections$.pipe(
map((collections) => getNestedCollectionTree(collections)),
const nestedCollections$ = combineLatest([
allCollections$,
this.configService.getFeatureFlag$(FeatureFlag.OptimizeNestedTraverseTypescript),
]).pipe(
map(([collections, shouldOptimize]) =>
shouldOptimize
? getNestedCollectionTree_vNext(collections)
: getNestedCollectionTree(collections),
),
);
this.searchText$

View File

@@ -27,6 +27,9 @@ export const mockCiphers: any[] = [
createLoginUriView("accounts.google.com"),
createLoginUriView("https://www.google.com"),
createLoginUriView("https://www.google.com/login"),
createLoginUriView("www.invalid@uri@.com"),
createLoginUriView("www.invaliduri!.com"),
createLoginUriView("this_is-not|a-valid-uri123@+"),
],
},
edit: false,

View File

@@ -50,7 +50,7 @@ describe("RiskInsightsReportService", () => {
let testCase = testCaseResults[0];
expect(testCase).toBeTruthy();
expect(testCase.cipherMembers).toHaveLength(2);
expect(testCase.trimmedUris).toHaveLength(2);
expect(testCase.trimmedUris).toHaveLength(5);
expect(testCase.weakPasswordDetail).toBeTruthy();
expect(testCase.exposedPasswordDetail).toBeTruthy();
expect(testCase.reusedPasswordCount).toEqual(2);
@@ -69,12 +69,16 @@ describe("RiskInsightsReportService", () => {
it("should generate the raw data + uri report correctly", async () => {
const result = await firstValueFrom(service.generateRawDataUriReport$("orgId"));
expect(result).toHaveLength(8);
expect(result).toHaveLength(11);
// Two ciphers that have google.com as their uri. There should be 2 results
const googleResults = result.filter((x) => x.trimmedUri === "google.com");
expect(googleResults).toHaveLength(2);
// There is an invalid uri and it should not be trimmed
const invalidUriResults = result.filter((x) => x.trimmedUri === "this_is-not|a-valid-uri123@+");
expect(invalidUriResults).toHaveLength(1);
// Verify the details for one of the googles matches the password health info
// expected
const firstGoogle = googleResults.filter(
@@ -88,7 +92,7 @@ describe("RiskInsightsReportService", () => {
it("should generate applications health report data correctly", async () => {
const result = await firstValueFrom(service.generateApplicationsReport$("orgId"));
expect(result).toHaveLength(5);
expect(result).toHaveLength(8);
// Two ciphers have google.com associated with them. The first cipher
// has 2 members and the second has 4. However, the 2 members in the first
@@ -132,7 +136,7 @@ describe("RiskInsightsReportService", () => {
expect(reportSummary.totalMemberCount).toEqual(7);
expect(reportSummary.totalAtRiskMemberCount).toEqual(6);
expect(reportSummary.totalApplicationCount).toEqual(5);
expect(reportSummary.totalAtRiskApplicationCount).toEqual(4);
expect(reportSummary.totalApplicationCount).toEqual(8);
expect(reportSummary.totalAtRiskApplicationCount).toEqual(7);
});
});

View File

@@ -433,7 +433,7 @@ export class RiskInsightsReportService {
const cipherUris: string[] = [];
const uris = cipher.login?.uris ?? [];
uris.map((u: { uri: string }) => {
const uri = Utils.getDomain(u.uri);
const uri = Utils.getDomain(u.uri) ?? u.uri;
if (!cipherUris.includes(uri)) {
cipherUris.push(uri);
}

View File

@@ -7,6 +7,9 @@ import { MasterPasswordPolicyOptions } from "../../models/domain/master-password
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
/**
* The primary service for retrieving and evaluating policies from sync data.
*/
export abstract class PolicyService {
/**
* All policies for the provided user from sync data.
@@ -24,7 +27,7 @@ export abstract class PolicyService {
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
* @returns true if any policy of the specified type applies to the specified user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
* {@link Policy} objects and then filter by Policy.data.
@@ -35,8 +38,12 @@ export abstract class PolicyService {
/**
* Combines all Master Password policies that apply to the user.
* If you are evaluating Master Password policies before the first sync has completed,
* you must supply your own `policies` value.
* @param userId The user against whom the policy needs to be enforced.
* @param policies The policies to be evaluated; if null or undefined, it will default to using policies from sync data.
* @returns a set of options which represent the minimum Master Password settings that the user must
* comply with in order to comply with **all** Master Password policies.
* comply with in order to comply with **all** applicable Master Password policies.
*/
abstract masterPasswordPolicyOptions$: (
userId: UserId,
@@ -62,7 +69,17 @@ export abstract class PolicyService {
) => [ResetPasswordPolicyOptions, boolean];
}
/**
* An "internal" extension of the `PolicyService` which allows the update of policy data in the local sync data.
* This does not update any policies on the server.
*/
export abstract class InternalPolicyService extends PolicyService {
/**
* Upsert a policy in the local sync data. This does not update any policies on the server.
*/
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
/**
* Replace a policy in the local sync data. This does not update any policies on the server.
*/
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
}

View File

@@ -11,6 +11,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
invoiceCreatedDate: Date | null;
subPeriodEndDate: Date | null;
isSubscriptionCanceled: boolean;
organizationOccupiedSeats: number;
constructor(response: any) {
super(response);
@@ -25,6 +26,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate"));
this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate"));
this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled");
this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats");
}
private parseDate(dateString: any): Date | null {

View File

@@ -13,6 +13,7 @@ export enum FeatureFlag {
/* Admin Console Team */
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions",
OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript",
/* Auth */
PM16117_ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor",
@@ -22,7 +23,6 @@ export enum FeatureFlag {
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
IdpAutoSubmitLogin = "idp-auto-submit-login",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
@@ -60,6 +60,7 @@ export enum FeatureFlag {
CipherKeyEncryption = "cipher-key-encryption",
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
EndUserNotifications = "pm-10609-end-user-notifications",
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -82,12 +83,12 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.LimitItemDeletion]: FALSE,
[FeatureFlag.SeparateCustomRolePermissions]: FALSE,
[FeatureFlag.OptimizeNestedTraverseTypescript]: FALSE,
/* Autofill */
[FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
@@ -109,6 +110,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
[FeatureFlag.EndUserNotifications]: FALSE,
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
/* Auth */
[FeatureFlag.PM16117_ChangeExistingPasswordRefactor]: FALSE,

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

@@ -36,6 +36,24 @@ 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

@@ -3,15 +3,6 @@
import { ITreeNodeObject, TreeNode } from "./models/domain/tree-node";
export class ServiceUtils {
/**
* Recursively adds a node to nodeTree
* @param {TreeNode<ITreeNodeObject>[]} nodeTree - An array of TreeNodes that the node will be added to
* @param {number} partIndex - Index of the `parts` array that is being processed
* @param {string[]} parts - Array of strings that represent the path to the `obj` node
* @param {ITreeNodeObject} obj - The node to be added to the tree
* @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(
nodeTree: TreeNode<ITreeNodeObject>[],
partIndex: number,
@@ -70,11 +61,75 @@ export class ServiceUtils {
}
}
/**
* Recursively adds a node to nodeTree
* @param {TreeNode<ITreeNodeObject>[]} nodeTree - An array of TreeNodes that the node will be added to
* @param {number} partIndex - Index of the `parts` array that is being processed
* @param {string[]} parts - Array of strings that represent the path to the `obj` node
* @param {ITreeNodeObject} obj - The node to be added to the tree
* @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(
nodeTree: TreeNode<ITreeNodeObject>[],
partIndex: number,
parts: string[],
obj: ITreeNodeObject,
parent: TreeNode<ITreeNodeObject> | undefined,
delimiter: string,
) {
if (parts.length <= partIndex) {
return;
}
// 'end' indicates we've traversed as far as we can based on the object name
const end: boolean = partIndex === parts.length - 1;
const partName: string = parts[partIndex];
// If we're at the end, just add the node - it doesn't matter what else is here
if (end) {
nodeTree.push(new TreeNode(obj, parent, partName));
return;
}
// Get matching nodes at this level by name
// NOTE: this is effectively a loop so we only want to do it once
const matchingNodes = nodeTree.filter((n) => n.node.name === partName);
// If there are no matching nodes...
if (matchingNodes.length === 0) {
// And we're not at the end of the path (because we didn't trigger the early return above),
// 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_vNext(
nodeTree,
0,
[newPartName, ...parts.slice(partIndex + 2)],
obj,
parent,
delimiter,
);
} else {
// There is a node here with the same name, descend into it
ServiceUtils.nestedTraverse_vNext(
matchingNodes[0].children,
partIndex + 1,
parts,
obj,
matchingNodes[0],
delimiter,
);
return;
}
}
/**
* Searches a tree for a node with a matching `id`
* @param {TreeNode<T>} nodeTree - A single TreeNode branch that will be searched
* @param {TreeNode<T extends ITreeNodeObject>} nodeTree - A single TreeNode branch that will be searched
* @param {string} id - The id of the node to be found
* @returns {TreeNode<T>} The node with a matching `id`
* @returns {TreeNode<T extends ITreeNodeObject>} The node with a matching `id`
*/
static getTreeNodeObject<T extends ITreeNodeObject>(
nodeTree: TreeNode<T>,
@@ -96,9 +151,9 @@ export class ServiceUtils {
/**
* Searches an array of tree nodes for a node with a matching `id`
* @param {TreeNode<T>} nodeTree - An array of TreeNode branches that will be searched
* @param {TreeNode<T extends ITreeNodeObject>} nodeTree - An array of TreeNode branches that will be searched
* @param {string} id - The id of the node to be found
* @returns {TreeNode<T>} The node with a matching `id`
* @returns {TreeNode<T extends ITreeNodeObject>} The node with a matching `id`
*/
static getTreeNodeObjectFromList<T extends ITreeNodeObject>(
nodeTree: TreeNode<T>[],

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