mirror of
https://github.com/bitwarden/browser
synced 2026-02-21 11:54:02 +00:00
Merge branch 'main' into km/pm-27297
This commit is contained in:
@@ -39,7 +39,7 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
of(Date.now() - THIRTY_DAYS_MS),
|
||||
from(this.pinService.isPinSet(userId)),
|
||||
this.biometricStateService.biometricUnlockEnabled$,
|
||||
this.biometricStateService.biometricUnlockEnabled$(userId),
|
||||
this.organizationService.organizations$(userId),
|
||||
this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId),
|
||||
]).pipe(
|
||||
|
||||
@@ -28,6 +28,41 @@ export const EVENTS = {
|
||||
SUBMIT: "submit",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* HTML attributes observed by the MutationObserver for autofill form/field tracking.
|
||||
* If you need to observe a new attribute, add it here.
|
||||
*/
|
||||
export const AUTOFILL_ATTRIBUTES = {
|
||||
ACTION: "action",
|
||||
ARIA_DESCRIBEDBY: "aria-describedby",
|
||||
ARIA_DISABLED: "aria-disabled",
|
||||
ARIA_HASPOPUP: "aria-haspopup",
|
||||
ARIA_HIDDEN: "aria-hidden",
|
||||
ARIA_LABEL: "aria-label",
|
||||
ARIA_LABELLEDBY: "aria-labelledby",
|
||||
AUTOCOMPLETE: "autocomplete",
|
||||
AUTOCOMPLETE_TYPE: "autocompletetype",
|
||||
X_AUTOCOMPLETE_TYPE: "x-autocompletetype",
|
||||
CHECKED: "checked",
|
||||
CLASS: "class",
|
||||
DATA_LABEL: "data-label",
|
||||
DATA_STRIPE: "data-stripe",
|
||||
DISABLED: "disabled",
|
||||
ID: "id",
|
||||
MAXLENGTH: "maxlength",
|
||||
METHOD: "method",
|
||||
NAME: "name",
|
||||
PLACEHOLDER: "placeholder",
|
||||
POPOVER: "popover",
|
||||
POPOVERTARGET: "popovertarget",
|
||||
POPOVERTARGETACTION: "popovertargetaction",
|
||||
READONLY: "readonly",
|
||||
REL: "rel",
|
||||
TABINDEX: "tabindex",
|
||||
TITLE: "title",
|
||||
TYPE: "type",
|
||||
} as const;
|
||||
|
||||
export const ClearClipboardDelay = {
|
||||
Never: null as null,
|
||||
TenSeconds: 10,
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Auth */
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
SafariAccountSwitching = "pm-5594-safari-account-switching",
|
||||
|
||||
/* Autofill */
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
@@ -133,6 +134,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
[FeatureFlag.SafariAccountSwitching]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
|
||||
@@ -11,6 +11,20 @@ import { PinLockType } from "./pin-lock-type";
|
||||
* The PinStateService manages the storage and retrieval of PIN-related state for user accounts.
|
||||
*/
|
||||
export abstract class PinStateServiceAbstraction {
|
||||
/**
|
||||
* Checks if a user is enrolled into PIN unlock
|
||||
* @param userId The user's id
|
||||
* @throws If the user id is not provided
|
||||
*/
|
||||
abstract pinSet$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Gets the user's {@link PinLockType}
|
||||
* @param userId The user's id
|
||||
* @throws If the user id is not provided
|
||||
*/
|
||||
abstract pinLockType$(userId: UserId): Observable<PinLockType>;
|
||||
|
||||
/**
|
||||
* Gets the user's UserKey encrypted PIN
|
||||
* @deprecated - This is not a public API. DO NOT USE IT
|
||||
@@ -21,17 +35,12 @@ export abstract class PinStateServiceAbstraction {
|
||||
|
||||
/**
|
||||
* Gets the user's {@link PinLockType}
|
||||
* @deprecated Use {@link pinLockType$} instead
|
||||
* @param userId The user's id
|
||||
* @throws If the user id is not provided
|
||||
*/
|
||||
abstract getPinLockType(userId: UserId): Promise<PinLockType>;
|
||||
|
||||
/**
|
||||
* Checks if a user is enrolled into PIN unlock
|
||||
* @param userId The user's id
|
||||
*/
|
||||
abstract isPinSet(userId: UserId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets the user's PIN-protected UserKey envelope, either persistent or ephemeral based on the provided PinLockType
|
||||
* @deprecated - This is not a public API. DO NOT USE IT
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
@@ -26,27 +26,36 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
.pipe(map((value) => (value ? new EncString(value) : null)));
|
||||
}
|
||||
|
||||
async isPinSet(userId: UserId): Promise<boolean> {
|
||||
pinSet$(userId: UserId): Observable<boolean> {
|
||||
assertNonNullish(userId, "userId");
|
||||
return (await this.getPinLockType(userId)) !== "DISABLED";
|
||||
return this.pinLockType$(userId).pipe(map((pinLockType) => pinLockType !== "DISABLED"));
|
||||
}
|
||||
|
||||
pinLockType$(userId: UserId): Observable<PinLockType> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
return combineLatest([
|
||||
this.pinProtectedUserKeyEnvelope$(userId, "PERSISTENT").pipe(map((key) => key != null)),
|
||||
this.stateProvider
|
||||
.getUserState$(USER_KEY_ENCRYPTED_PIN, userId)
|
||||
.pipe(map((key) => key != null)),
|
||||
]).pipe(
|
||||
map(([isPersistentPinSet, isPinSet]) => {
|
||||
if (isPersistentPinSet) {
|
||||
return "PERSISTENT";
|
||||
} else if (isPinSet) {
|
||||
return "EPHEMERAL";
|
||||
} else {
|
||||
return "DISABLED";
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getPinLockType(userId: UserId): Promise<PinLockType> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
const isPersistentPinSet =
|
||||
(await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null;
|
||||
const isPinSet =
|
||||
(await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) !=
|
||||
null;
|
||||
|
||||
if (isPersistentPinSet) {
|
||||
return "PERSISTENT";
|
||||
} else if (isPinSet) {
|
||||
return "EPHEMERAL";
|
||||
} else {
|
||||
return "DISABLED";
|
||||
}
|
||||
return await firstValueFrom(this.pinLockType$(userId));
|
||||
}
|
||||
|
||||
async getPinProtectedUserKeyEnvelope(
|
||||
@@ -55,17 +64,7 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
): Promise<PasswordProtectedKeyEnvelope | null> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
if (pinLockType === "EPHEMERAL") {
|
||||
return await firstValueFrom(
|
||||
this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId),
|
||||
);
|
||||
} else if (pinLockType === "PERSISTENT") {
|
||||
return await firstValueFrom(
|
||||
this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId),
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Unsupported PinLockType: ${pinLockType}`);
|
||||
}
|
||||
return await firstValueFrom(this.pinProtectedUserKeyEnvelope$(userId, pinLockType));
|
||||
}
|
||||
|
||||
async setPinState(
|
||||
@@ -110,4 +109,19 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
|
||||
await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId);
|
||||
}
|
||||
|
||||
private pinProtectedUserKeyEnvelope$(
|
||||
userId: UserId,
|
||||
pinLockType: PinLockType,
|
||||
): Observable<PasswordProtectedKeyEnvelope | null> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
if (pinLockType === "EPHEMERAL") {
|
||||
return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId);
|
||||
} else if (pinLockType === "PERSISTENT") {
|
||||
return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId);
|
||||
} else {
|
||||
throw new Error(`Unsupported PinLockType: ${pinLockType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
|
||||
|
||||
@@ -94,14 +94,50 @@ describe("PinStateService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPinLockType()", () => {
|
||||
describe("pinSet$", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should throw an error if userId is null", async () => {
|
||||
// Act & Assert
|
||||
await expect(sut.getPinLockType(null as any)).rejects.toThrow("userId");
|
||||
expect(() => sut.pinSet$(null as any)).toThrow("userId");
|
||||
});
|
||||
|
||||
it("should return false when pin lock type is DISABLED", async () => {
|
||||
// Arrange
|
||||
jest.spyOn(sut, "pinLockType$").mockReturnValue(of("DISABLED"));
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(sut.pinSet$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it.each([["PERSISTENT" as PinLockType], ["EPHEMERAL" as PinLockType]])(
|
||||
"should return true when pin lock type is %s",
|
||||
async (pinLockType) => {
|
||||
// Arrange
|
||||
jest.spyOn(sut, "pinLockType$").mockReturnValue(of(pinLockType));
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(sut.pinSet$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("pinLockType$", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should throw an error if userId is null", async () => {
|
||||
// Act & Assert
|
||||
expect(() => sut.pinLockType$(null as any)).toThrow("userId");
|
||||
});
|
||||
|
||||
it("should return 'PERSISTENT' if a pin protected user key (persistent) is found", async () => {
|
||||
@@ -114,7 +150,7 @@ describe("PinStateService", () => {
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("PERSISTENT");
|
||||
@@ -125,7 +161,7 @@ describe("PinStateService", () => {
|
||||
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("EPHEMERAL");
|
||||
@@ -135,7 +171,7 @@ describe("PinStateService", () => {
|
||||
// Arrange - don't set any PIN-related state
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("DISABLED");
|
||||
@@ -151,7 +187,7 @@ describe("PinStateService", () => {
|
||||
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
const result = await firstValueFrom(sut.pinLockType$(mockUserId));
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("DISABLED");
|
||||
|
||||
@@ -20,10 +20,9 @@ export abstract class VaultTimeoutSettingsService {
|
||||
/**
|
||||
* Get the available vault timeout actions for the current user
|
||||
*
|
||||
* **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes
|
||||
* @param userId The user id to check. If not provided, the current user is used
|
||||
*/
|
||||
abstract availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]>;
|
||||
abstract availableVaultTimeoutActions$(userId?: UserId): Observable<VaultTimeoutAction[]>;
|
||||
|
||||
/**
|
||||
* Evaluates the user's available vault timeout actions and returns a boolean representing
|
||||
@@ -55,5 +54,5 @@ export abstract class VaultTimeoutSettingsService {
|
||||
* @param userId The user id to check. If not provided, the current user is used
|
||||
* @returns boolean true if biometric lock is set
|
||||
*/
|
||||
abstract isBiometricLockSet(userId?: string): Promise<boolean>;
|
||||
abstract isBiometricLockSet(userId?: UserId): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
|
||||
vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout);
|
||||
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
pinStateService.pinSet$.mockReturnValue(of(false));
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,72 +87,121 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
});
|
||||
|
||||
describe("availableVaultTimeoutActions$", () => {
|
||||
it("always returns LogOut", async () => {
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
describe("when no userId provided (active user)", () => {
|
||||
it("always returns LogOut", async () => {
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has a master password", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
);
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => {
|
||||
pinStateService.pinSet$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has biometrics configured", async () => {
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true));
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
|
||||
pinStateService.pinSet$.mockReturnValue(of(false));
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("should throw error when activeAccount$ is null", async () => {
|
||||
accountService.activeAccountSubject.next(null);
|
||||
|
||||
const result$ = vaultTimeoutSettingsService.availableVaultTimeoutActions$();
|
||||
|
||||
await expect(firstValueFrom(result$)).rejects.toThrow("Null or undefined account");
|
||||
});
|
||||
});
|
||||
|
||||
it("contains Lock when the user has a master password", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
describe("with explicit userId parameter", () => {
|
||||
it("should return Lock and LogOut when provided user has master password", async () => {
|
||||
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
);
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => {
|
||||
pinStateService.isPinSet.mockResolvedValue(true);
|
||||
it("should return Lock and LogOut when provided user has PIN configured", async () => {
|
||||
pinStateService.pinSet$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
expect(pinStateService.pinSet$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("contains Lock when the user has biometrics configured", async () => {
|
||||
biometricStateService.biometricUnlockEnabled$ = of(true);
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
it("should return Lock and LogOut when provided user has biometrics configured", async () => {
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(result).toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
|
||||
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
|
||||
pinStateService.isPinSet.mockResolvedValue(false);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
it("should not return Lock when provided user has no unlock methods", async () => {
|
||||
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
|
||||
pinStateService.pinSet$.mockReturnValue(of(false));
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false));
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("should return only LogOut when userId is not provided and there is no active account", async () => {
|
||||
// Set up accountService to return null for activeAccount
|
||||
accountService.activeAccount$ = of(null);
|
||||
pinStateService.isPinSet.mockResolvedValue(false);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
|
||||
// Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
// Since there's no active account, userHasMasterPassword returns false,
|
||||
// meaning no master password is available, so Lock should not be available
|
||||
expect(result).toEqual([VaultTimeoutAction.LogOut]);
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
expect(result).toContain(VaultTimeoutAction.LogOut);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,8 +287,8 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
`(
|
||||
"returns $expected when policy is $policy, has PIN unlock method: $hasPinUnlock or Biometric unlock method: $hasBiometricUnlock, and user preference is $userPreference",
|
||||
async ({ hasPinUnlock, hasBiometricUnlock, policy, userPreference, expected }) => {
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(hasBiometricUnlock);
|
||||
pinStateService.isPinSet.mockResolvedValue(hasPinUnlock);
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(hasBiometricUnlock));
|
||||
pinStateService.pinSet$.mockReturnValue(of(hasPinUnlock));
|
||||
|
||||
userDecryptionOptionsSubject.next(
|
||||
new UserDecryptionOptions({ hasMasterPassword: false }),
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
of,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
concatMap,
|
||||
} from "rxjs";
|
||||
|
||||
@@ -28,6 +27,7 @@ import { PolicyType } from "../../../admin-console/enums";
|
||||
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { getUserId } from "../../../auth/services/account.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -101,8 +101,29 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
await this.keyService.refreshAdditionalKeys(userId);
|
||||
}
|
||||
|
||||
availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]> {
|
||||
return defer(() => this.getAvailableVaultTimeoutActions(userId));
|
||||
availableVaultTimeoutActions$(userId?: UserId): Observable<VaultTimeoutAction[]> {
|
||||
const userId$ =
|
||||
userId != null
|
||||
? of(userId)
|
||||
: // TODO remove with https://bitwarden.atlassian.net/browse/PM-10647
|
||||
getUserId(this.accountService.activeAccount$);
|
||||
|
||||
return userId$.pipe(
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.userDecryptionOptionsService.hasMasterPasswordById$(userId),
|
||||
this.biometricStateService.biometricUnlockEnabled$(userId),
|
||||
this.pinStateService.pinSet$(userId),
|
||||
]),
|
||||
),
|
||||
map(([haveMasterPassword, biometricUnlockEnabled, isPinSet]) => {
|
||||
const canLock = haveMasterPassword || biometricUnlockEnabled || isPinSet;
|
||||
if (canLock) {
|
||||
return [VaultTimeoutAction.LogOut, VaultTimeoutAction.Lock];
|
||||
}
|
||||
return [VaultTimeoutAction.LogOut];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async canLock(userId: UserId): Promise<boolean> {
|
||||
@@ -112,12 +133,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
return availableVaultTimeoutActions?.includes(VaultTimeoutAction.Lock) || false;
|
||||
}
|
||||
|
||||
async isBiometricLockSet(userId?: string): Promise<boolean> {
|
||||
const biometricUnlockPromise =
|
||||
userId == null
|
||||
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: this.biometricStateService.getBiometricUnlockEnabled(userId as UserId);
|
||||
return await biometricUnlockPromise;
|
||||
async isBiometricLockSet(userId?: UserId): Promise<boolean> {
|
||||
return await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId));
|
||||
}
|
||||
|
||||
private async setVaultTimeout(userId: UserId, timeout: VaultTimeout): Promise<void> {
|
||||
@@ -262,45 +279,45 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
return combineLatest([
|
||||
this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId),
|
||||
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
|
||||
this.availableVaultTimeoutActions$(userId),
|
||||
]).pipe(
|
||||
switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => {
|
||||
return from(
|
||||
this.determineVaultTimeoutAction(
|
||||
userId,
|
||||
concatMap(
|
||||
async ([
|
||||
currentVaultTimeoutAction,
|
||||
maxSessionTimeoutPolicyData,
|
||||
availableVaultTimeoutActions,
|
||||
]) => {
|
||||
const vaultTimeoutAction = this.determineVaultTimeoutAction(
|
||||
availableVaultTimeoutActions,
|
||||
currentVaultTimeoutAction,
|
||||
maxSessionTimeoutPolicyData,
|
||||
),
|
||||
).pipe(
|
||||
tap((vaultTimeoutAction: VaultTimeoutAction) => {
|
||||
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
|
||||
// We want to avoid having a null timeout action always so we set it to the default if it is null
|
||||
// and if the user becomes subject to a policy that requires a specific action, we set it to that
|
||||
if (vaultTimeoutAction !== currentVaultTimeoutAction) {
|
||||
return this.stateProvider.setUserState(
|
||||
VAULT_TIMEOUT_ACTION,
|
||||
vaultTimeoutAction,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
// Protect outer observable from canceling on error by catching and returning EMPTY
|
||||
this.logService.error(`Error getting vault timeout: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
);
|
||||
|
||||
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
|
||||
// We want to avoid having a null timeout action always so we set it to the default if it is null
|
||||
// and if the user becomes subject to a policy that requires a specific action, we set it to that
|
||||
if (vaultTimeoutAction !== currentVaultTimeoutAction) {
|
||||
await this.stateProvider.setUserState(VAULT_TIMEOUT_ACTION, vaultTimeoutAction, userId);
|
||||
}
|
||||
|
||||
return vaultTimeoutAction;
|
||||
},
|
||||
),
|
||||
catchError((error: unknown) => {
|
||||
// Protect outer observable from canceling on error by catching and returning EMPTY
|
||||
this.logService.error(`Error getting vault timeout: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
distinctUntilChanged(), // Avoid having the set side effect trigger a new emission of the same action
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
private async determineVaultTimeoutAction(
|
||||
userId: string,
|
||||
private determineVaultTimeoutAction(
|
||||
availableVaultTimeoutActions: VaultTimeoutAction[],
|
||||
currentVaultTimeoutAction: VaultTimeoutAction | null,
|
||||
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
|
||||
): Promise<VaultTimeoutAction> {
|
||||
const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId);
|
||||
): VaultTimeoutAction {
|
||||
if (availableVaultTimeoutActions.length === 1) {
|
||||
return availableVaultTimeoutActions[0];
|
||||
}
|
||||
@@ -339,38 +356,4 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
|
||||
);
|
||||
}
|
||||
|
||||
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {
|
||||
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
|
||||
const availableActions = [VaultTimeoutAction.LogOut];
|
||||
|
||||
const canLock =
|
||||
(await this.userHasMasterPassword(userId)) ||
|
||||
(await this.pinStateService.isPinSet(userId as UserId)) ||
|
||||
(await this.isBiometricLockSet(userId));
|
||||
|
||||
if (canLock) {
|
||||
availableActions.push(VaultTimeoutAction.Lock);
|
||||
}
|
||||
|
||||
return availableActions;
|
||||
}
|
||||
|
||||
private async userHasMasterPassword(userId: string): Promise<boolean> {
|
||||
let resolvedUserId: UserId;
|
||||
if (userId) {
|
||||
resolvedUserId = userId as UserId;
|
||||
} else {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!activeAccount) {
|
||||
return false; // No account, can't have master password
|
||||
}
|
||||
resolvedUserId = activeAccount.id;
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
/**
|
||||
@@ -34,4 +34,76 @@ export abstract class CipherSdkService {
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined>;
|
||||
|
||||
/**
|
||||
* Deletes a cipher on the server using the SDK.
|
||||
*
|
||||
* @param id The cipher ID to delete
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves when the cipher is deleted
|
||||
*/
|
||||
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes multiple ciphers on the server using the SDK.
|
||||
*
|
||||
* @param ids The cipher IDs to delete
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @param orgId The organization ID (required when asAdmin is true)
|
||||
* @returns A promise that resolves when the ciphers are deleted
|
||||
*/
|
||||
abstract deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Soft deletes a cipher on the server using the SDK.
|
||||
*
|
||||
* @param id The cipher ID to soft delete
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves when the cipher is soft deleted
|
||||
*/
|
||||
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Soft deletes multiple ciphers on the server using the SDK.
|
||||
*
|
||||
* @param ids The cipher IDs to soft delete
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @param orgId The organization ID (required when asAdmin is true)
|
||||
* @returns A promise that resolves when the ciphers are soft deleted
|
||||
*/
|
||||
abstract softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Restores a soft-deleted cipher on the server using the SDK.
|
||||
*
|
||||
* @param id The cipher ID to restore
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param asAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves when the cipher is restored
|
||||
*/
|
||||
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Restores multiple soft-deleted ciphers on the server using the SDK.
|
||||
*
|
||||
* @param ids The cipher IDs to restore
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param orgId The organization ID (determines whether to use admin API)
|
||||
* @returns A promise that resolves when the ciphers are restored
|
||||
*/
|
||||
abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -230,8 +230,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
abstract clear(userId?: string): Promise<void>;
|
||||
abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any>;
|
||||
abstract delete(id: string | string[], userId: UserId): Promise<any>;
|
||||
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract deleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void>;
|
||||
abstract deleteAttachment(
|
||||
id: string,
|
||||
revisionDate: string,
|
||||
@@ -247,14 +252,19 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number;
|
||||
abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number;
|
||||
abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number;
|
||||
abstract softDelete(id: string | string[], userId: UserId): Promise<any>;
|
||||
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract softDelete(id: string | string[], userId: UserId): Promise<void>;
|
||||
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void>;
|
||||
abstract restore(
|
||||
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[],
|
||||
userId: UserId,
|
||||
): Promise<any>;
|
||||
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
): Promise<void>;
|
||||
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void>;
|
||||
abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<any>;
|
||||
abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise<void>;
|
||||
@@ -275,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
abstract getNextIdentityCipher(userId: UserId): Promise<CipherView>;
|
||||
|
||||
/**
|
||||
* Decrypts a cipher using either the SDK or the legacy method based on the feature flag.
|
||||
* Decrypts a cipher using either the use-sdk-cipheroperationsSDK or the legacy method based on the feature flag.
|
||||
* @param cipher The cipher to decrypt.
|
||||
* @param userId The user ID to use for decryption.
|
||||
* @returns A promise that resolves to the decrypted cipher view.
|
||||
|
||||
@@ -16,11 +16,6 @@ export abstract class VaultSettingsService {
|
||||
* An observable monitoring the state of the show identities on the current tab.
|
||||
*/
|
||||
abstract showIdentitiesCurrentTab$: Observable<boolean>;
|
||||
/**
|
||||
* An observable monitoring the state of the click items on the Vault view
|
||||
* for Autofill suggestions.
|
||||
*/
|
||||
abstract clickItemsToAutofillVaultView$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Saves the enable passkeys setting to disk.
|
||||
@@ -37,10 +32,4 @@ export abstract class VaultSettingsService {
|
||||
* @param value The new value for the show identities on tab page setting.
|
||||
*/
|
||||
abstract setShowIdentitiesCurrentTab(value: boolean): Promise<void>;
|
||||
/**
|
||||
* Saves the click items on vault View for Autofill suggestions to disk.
|
||||
* @param value The new value for the click items on vault View for
|
||||
* Autofill suggestions setting.
|
||||
*/
|
||||
abstract setClickItemsToAutofillVaultView(value: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,22 @@ describe("DefaultCipherSdkService", () => {
|
||||
mockAdminSdk = {
|
||||
create: jest.fn(),
|
||||
edit: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
delete_many: jest.fn().mockResolvedValue(undefined),
|
||||
soft_delete: jest.fn().mockResolvedValue(undefined),
|
||||
soft_delete_many: jest.fn().mockResolvedValue(undefined),
|
||||
restore: jest.fn().mockResolvedValue(undefined),
|
||||
restore_many: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockCiphersSdk = {
|
||||
create: jest.fn(),
|
||||
edit: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
delete_many: jest.fn().mockResolvedValue(undefined),
|
||||
soft_delete: jest.fn().mockResolvedValue(undefined),
|
||||
soft_delete_many: jest.fn().mockResolvedValue(undefined),
|
||||
restore: jest.fn().mockResolvedValue(undefined),
|
||||
restore_many: jest.fn().mockResolvedValue(undefined),
|
||||
admin: jest.fn().mockReturnValue(mockAdminSdk),
|
||||
};
|
||||
mockVaultSdk = {
|
||||
@@ -243,4 +255,280 @@ describe("DefaultCipherSdkService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should delete cipher using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.deleteWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.delete).toHaveBeenCalledWith(testCipherId);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should delete cipher using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.deleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.delete).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to delete cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.delete.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to delete cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should delete multiple ciphers using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.deleteManyWithServer(testCipherIds, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.delete_many).toHaveBeenCalledWith(testCipherIds);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should delete multiple ciphers using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.delete_many).toHaveBeenCalledWith(testCipherIds, orgId);
|
||||
});
|
||||
|
||||
it("should throw error when asAdmin is true but orgId is missing", async () => {
|
||||
await expect(
|
||||
cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, undefined),
|
||||
).rejects.toThrow("Organization ID is required for admin delete.");
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to delete multiple ciphers"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.delete_many.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to delete multiple ciphers"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should soft delete cipher using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.softDeleteWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.soft_delete).toHaveBeenCalledWith(testCipherId);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should soft delete cipher using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.softDeleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.soft_delete).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to soft delete cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.soft_delete.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to soft delete cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should soft delete multiple ciphers using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should soft delete multiple ciphers using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds, orgId);
|
||||
});
|
||||
|
||||
it("should throw error when asAdmin is true but orgId is missing", async () => {
|
||||
await expect(
|
||||
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, undefined),
|
||||
).rejects.toThrow("Organization ID is required for admin soft delete.");
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId),
|
||||
).rejects.toThrow("SDK not available");
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to soft delete multiple ciphers"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.soft_delete_many.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(
|
||||
cipherSdkService.softDeleteManyWithServer(testCipherIds, userId),
|
||||
).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to soft delete multiple ciphers"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should restore cipher using SDK when asAdmin is false", async () => {
|
||||
await cipherSdkService.restoreWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.restore).toHaveBeenCalledWith(testCipherId);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should restore cipher using SDK admin API when asAdmin is true", async () => {
|
||||
await cipherSdkService.restoreWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.restore).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to restore cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.restore.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to restore cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should restore multiple ciphers using SDK when orgId is not provided", async () => {
|
||||
await cipherSdkService.restoreManyWithServer(testCipherIds, userId);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.restore_many).toHaveBeenCalledWith(testCipherIds);
|
||||
expect(mockCiphersSdk.admin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should restore multiple ciphers using SDK admin API when orgId is provided", async () => {
|
||||
const orgIdString = orgId as string;
|
||||
await cipherSdkService.restoreManyWithServer(testCipherIds, userId, orgIdString);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.restore_many).toHaveBeenCalledWith(testCipherIds, orgIdString);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
|
||||
await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow(
|
||||
"SDK not available",
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to restore multiple ciphers"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
mockCiphersSdk.restore_many.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to restore multiple ciphers"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { firstValueFrom, switchMap, catchError } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { SdkService, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
|
||||
|
||||
@@ -79,4 +79,185 @@ export class DefaultCipherSdkService implements CipherSdkService {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
await ref.value.vault().ciphers().admin().delete(asUuid(id));
|
||||
} else {
|
||||
await ref.value.vault().ciphers().delete(asUuid(id));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to delete cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
if (orgId == null) {
|
||||
throw new Error("Organization ID is required for admin delete.");
|
||||
}
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.admin()
|
||||
.delete_many(
|
||||
ids.map((id) => asUuid(id)),
|
||||
asUuid(orgId),
|
||||
);
|
||||
} else {
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.delete_many(ids.map((id) => asUuid(id)));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to delete multiple ciphers: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
await ref.value.vault().ciphers().admin().soft_delete(asUuid(id));
|
||||
} else {
|
||||
await ref.value.vault().ciphers().soft_delete(asUuid(id));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to soft delete cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
if (orgId == null) {
|
||||
throw new Error("Organization ID is required for admin soft delete.");
|
||||
}
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.admin()
|
||||
.soft_delete_many(
|
||||
ids.map((id) => asUuid(id)),
|
||||
asUuid(orgId),
|
||||
);
|
||||
} else {
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.soft_delete_many(ids.map((id) => asUuid(id)));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to soft delete multiple ciphers: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
if (asAdmin) {
|
||||
await ref.value.vault().ciphers().admin().restore(asUuid(id));
|
||||
} else {
|
||||
await ref.value.vault().ciphers().restore(asUuid(id));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to restore cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
|
||||
// No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable
|
||||
// The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore
|
||||
if (orgId) {
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.admin()
|
||||
.restore_many(
|
||||
ids.map((id) => asUuid(id)),
|
||||
asUuid(orgId),
|
||||
);
|
||||
} else {
|
||||
await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.restore_many(ids.map((id) => asUuid(id)));
|
||||
}
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to restore multiple ciphers: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,8 @@ describe("Cipher Service", () => {
|
||||
|
||||
let cipherService: CipherService;
|
||||
let encryptionContext: EncryptionContext;
|
||||
// BehaviorSubject for SDK feature flag - allows tests to change the value after service instantiation
|
||||
let sdkCrudFeatureFlag$: BehaviorSubject<boolean>;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
|
||||
@@ -132,6 +134,10 @@ describe("Cipher Service", () => {
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
|
||||
// Create BehaviorSubject for SDK feature flag - tests can update this to change behavior
|
||||
sdkCrudFeatureFlag$ = new BehaviorSubject<boolean>(false);
|
||||
configService.getFeatureFlag$.mockReturnValue(sdkCrudFeatureFlag$.asObservable());
|
||||
|
||||
cipherService = new CipherService(
|
||||
keyService,
|
||||
domainSettingsService,
|
||||
@@ -280,9 +286,7 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
const expectedResult = new CipherView(encryptionContext.cipher);
|
||||
@@ -315,9 +319,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
|
||||
configService.getFeatureFlag
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
testCipher.organizationId = orgId;
|
||||
@@ -368,9 +372,7 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const cipherView = new CipherView(testCipher);
|
||||
@@ -392,9 +394,7 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const cipherView = new CipherView(testCipher);
|
||||
@@ -1009,6 +1009,238 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should call apiService.deleteCipher when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "deleteCipher").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.deleteWithServer(testCipherId, userId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should call apiService.deleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "deleteCipherAdmin").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.deleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should use SDK to delete cipher when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "deleteWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.deleteWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should use SDK admin delete when feature flag is enabled and asAdmin is true", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "deleteWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.deleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should call apiService.deleteManyCiphers when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "deleteManyCiphers").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call apiService.deleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "deleteManyCiphersAdmin").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "deleteManyWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId, false);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should use SDK admin delete many when feature flag is enabled and asAdmin is true", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "deleteManyWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteWithServer()", () => {
|
||||
const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
|
||||
it("should call apiService.putDeleteCipher when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "putDeleteCipher").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.softDeleteWithServer(testCipherId, userId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should call apiService.putDeleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "putDeleteCipherAdmin").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.softDeleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(testCipherId);
|
||||
});
|
||||
|
||||
it("should use SDK to soft delete cipher when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "softDeleteWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.softDeleteWithServer(testCipherId, userId, false);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should use SDK admin soft delete when feature flag is enabled and asAdmin is true", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "softDeleteWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.softDeleteWithServer(testCipherId, userId, true);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteManyWithServer()", () => {
|
||||
const testCipherIds = [
|
||||
"5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
"6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
|
||||
];
|
||||
|
||||
it("should call apiService.putDeleteManyCiphers when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphers").mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.softDeleteManyWithServer(testCipherIds, userId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call apiService.putDeleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const apiSpy = jest
|
||||
.spyOn(apiService, "putDeleteManyCiphersAdmin")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "softDeleteManyWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.softDeleteManyWithServer(testCipherIds, userId, false);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should use SDK admin soft delete many when feature flag is enabled and asAdmin is true", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const sdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "softDeleteManyWithServer")
|
||||
.mockResolvedValue(undefined);
|
||||
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
|
||||
|
||||
await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId);
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace (no upsert)", () => {
|
||||
// In order to set up initial state we need to manually update the encrypted state
|
||||
// which will result in an emission. All tests will have this baseline emission.
|
||||
|
||||
@@ -106,6 +106,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
*/
|
||||
private clearCipherViewsForUser$: Subject<UserId> = new Subject<UserId>();
|
||||
|
||||
/**
|
||||
* Observable exposing the feature flag status for using the SDK for cipher CRUD operations.
|
||||
*/
|
||||
private readonly sdkCipherCrudEnabled$: Observable<boolean> = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.PM27632_SdkCipherCrudOperations,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
@@ -909,9 +916,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const useSdk = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27632_SdkCipherCrudOperations,
|
||||
);
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
|
||||
if (useSdk) {
|
||||
return (
|
||||
@@ -970,9 +975,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const useSdk = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27632_SdkCipherCrudOperations,
|
||||
);
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
|
||||
if (useSdk) {
|
||||
return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin);
|
||||
@@ -1389,7 +1392,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.encryptedCiphersState(userId).update(() => ciphers);
|
||||
}
|
||||
|
||||
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.deleteWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (asAdmin) {
|
||||
await this.apiService.deleteCipherAdmin(id);
|
||||
} else {
|
||||
@@ -1399,7 +1409,19 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.delete(id, userId);
|
||||
}
|
||||
|
||||
async deleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise<any> {
|
||||
async deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new CipherBulkDeleteRequest(ids);
|
||||
if (asAdmin) {
|
||||
await this.apiService.deleteManyCiphersAdmin(request);
|
||||
@@ -1539,7 +1561,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
};
|
||||
}
|
||||
|
||||
async softDelete(id: string | string[], userId: UserId): Promise<any> {
|
||||
async softDelete(id: string | string[], userId: UserId): Promise<void> {
|
||||
let ciphers = await firstValueFrom(this.ciphers$(userId));
|
||||
if (ciphers == null) {
|
||||
return;
|
||||
@@ -1567,7 +1589,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (asAdmin) {
|
||||
await this.apiService.putDeleteCipherAdmin(id);
|
||||
} else {
|
||||
@@ -1577,7 +1606,19 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.softDelete(id, userId);
|
||||
}
|
||||
|
||||
async softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise<any> {
|
||||
async softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new CipherBulkDeleteRequest(ids);
|
||||
if (asAdmin) {
|
||||
await this.apiService.putDeleteManyCiphersAdmin(request);
|
||||
@@ -1621,7 +1662,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.restoreWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (asAdmin) {
|
||||
response = await this.apiService.putRestoreCipherAdmin(id);
|
||||
@@ -1637,6 +1685,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
* The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore
|
||||
*/
|
||||
async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
|
||||
if (orgId) {
|
||||
|
||||
@@ -25,12 +25,3 @@ export const SHOW_IDENTITIES_CURRENT_TAB = new UserKeyDefinition<boolean>(
|
||||
clearOn: [], // do not clear user settings
|
||||
},
|
||||
);
|
||||
|
||||
export const CLICK_ITEMS_AUTOFILL_VAULT_VIEW = new UserKeyDefinition<boolean>(
|
||||
VAULT_SETTINGS_DISK,
|
||||
"clickItemsToAutofillOnVaultView",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: [], // do not clear user settings
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Observable, combineLatest, map, shareReplay } from "rxjs";
|
||||
import { Observable, combineLatest, map } from "rxjs";
|
||||
|
||||
import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state";
|
||||
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service";
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
SHOW_CARDS_CURRENT_TAB,
|
||||
SHOW_IDENTITIES_CURRENT_TAB,
|
||||
USER_ENABLE_PASSKEYS,
|
||||
CLICK_ITEMS_AUTOFILL_VAULT_VIEW,
|
||||
} from "../key-state/vault-settings.state";
|
||||
import { RestrictedItemTypesService } from "../restricted-item-types.service";
|
||||
|
||||
@@ -49,17 +48,6 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
|
||||
readonly showIdentitiesCurrentTab$: Observable<boolean> =
|
||||
this.showIdentitiesCurrentTabState.state$.pipe(map((x) => x ?? true));
|
||||
|
||||
private clickItemsToAutofillVaultViewState: ActiveUserState<boolean> =
|
||||
this.stateProvider.getActive(CLICK_ITEMS_AUTOFILL_VAULT_VIEW);
|
||||
/**
|
||||
* {@link VaultSettingsServiceAbstraction.clickItemsToAutofillVaultView$$}
|
||||
*/
|
||||
readonly clickItemsToAutofillVaultView$: Observable<boolean> =
|
||||
this.clickItemsToAutofillVaultViewState.state$.pipe(
|
||||
map((x) => x ?? false),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private restrictedItemTypesService: RestrictedItemTypesService,
|
||||
@@ -79,13 +67,6 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction {
|
||||
await this.showIdentitiesCurrentTabState.update(() => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link VaultSettingsServiceAbstraction.setClickItemsToAutofillVaultView}
|
||||
*/
|
||||
async setClickItemsToAutofillVaultView(value: boolean): Promise<void> {
|
||||
await this.clickItemsToAutofillVaultViewState.update(() => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link VaultSettingsServiceAbstraction.setEnablePasskeys}
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FieldType } from "@bitwarden/common/vault/enums";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { KeePass2XmlImporter } from "./keepass2-xml-importer";
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
TestData,
|
||||
TestData1,
|
||||
TestData2,
|
||||
TestDataWithProtectedFields,
|
||||
} from "./spec-data/keepass2-xml/keepass2-xml-importer-testdata";
|
||||
|
||||
describe("KeePass2 Xml Importer", () => {
|
||||
@@ -43,4 +45,73 @@ describe("KeePass2 Xml Importer", () => {
|
||||
const result = await importer.parse(TestData2);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
describe("protected fields handling", () => {
|
||||
it("should import protected custom fields as hidden fields", async () => {
|
||||
const importer = new KeePass2XmlImporter();
|
||||
const result = await importer.parse(TestDataWithProtectedFields);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.ciphers.length).toBe(1);
|
||||
|
||||
const cipher = result.ciphers[0];
|
||||
expect(cipher.name).toBe("Test Entry");
|
||||
expect(cipher.login.username).toBe("testuser");
|
||||
expect(cipher.login.password).toBe("testpass");
|
||||
expect(cipher.notes).toContain("Regular notes");
|
||||
|
||||
// Check that protected custom field is imported as hidden field
|
||||
const protectedField = cipher.fields.find((f) => f.name === "SAFE UN-LOCKING instructions");
|
||||
expect(protectedField).toBeDefined();
|
||||
expect(protectedField?.value).toBe("Secret instructions here");
|
||||
expect(protectedField?.type).toBe(FieldType.Hidden);
|
||||
|
||||
// Check that regular custom field is imported as text field
|
||||
const regularField = cipher.fields.find((f) => f.name === "CustomField");
|
||||
expect(regularField).toBeDefined();
|
||||
expect(regularField?.value).toBe("Custom value");
|
||||
expect(regularField?.type).toBe(FieldType.Text);
|
||||
});
|
||||
|
||||
it("should import long protected fields as hidden fields (not appended to notes)", async () => {
|
||||
const importer = new KeePass2XmlImporter();
|
||||
const result = await importer.parse(TestDataWithProtectedFields);
|
||||
|
||||
const cipher = result.ciphers[0];
|
||||
|
||||
// Long protected field should be imported as hidden field
|
||||
const longField = cipher.fields.find((f) => f.name === "LongProtectedField");
|
||||
expect(longField).toBeDefined();
|
||||
expect(longField?.type).toBe(FieldType.Hidden);
|
||||
expect(longField?.value).toContain("This is a very long protected field");
|
||||
|
||||
// Should not be appended to notes
|
||||
expect(cipher.notes).not.toContain("LongProtectedField");
|
||||
});
|
||||
|
||||
it("should import multiline protected fields as hidden fields (not appended to notes)", async () => {
|
||||
const importer = new KeePass2XmlImporter();
|
||||
const result = await importer.parse(TestDataWithProtectedFields);
|
||||
|
||||
const cipher = result.ciphers[0];
|
||||
|
||||
// Multiline protected field should be imported as hidden field
|
||||
const multilineField = cipher.fields.find((f) => f.name === "MultilineProtectedField");
|
||||
expect(multilineField).toBeDefined();
|
||||
expect(multilineField?.type).toBe(FieldType.Hidden);
|
||||
expect(multilineField?.value).toContain("Line 1");
|
||||
|
||||
// Should not be appended to notes
|
||||
expect(cipher.notes).not.toContain("MultilineProtectedField");
|
||||
});
|
||||
|
||||
it("should not append protected custom fields to notes", async () => {
|
||||
const importer = new KeePass2XmlImporter();
|
||||
const result = await importer.parse(TestDataWithProtectedFields);
|
||||
|
||||
const cipher = result.ciphers[0];
|
||||
expect(cipher.notes).not.toContain("SAFE UN-LOCKING instructions");
|
||||
expect(cipher.notes).not.toContain("Secret instructions here");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FieldType } from "@bitwarden/common/vault/enums";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { ImportResult } from "../models/import-result";
|
||||
@@ -92,16 +93,26 @@ export class KeePass2XmlImporter extends BaseImporter implements Importer {
|
||||
} else if (key === "Notes") {
|
||||
cipher.notes += value + "\n";
|
||||
} else {
|
||||
let type = FieldType.Text;
|
||||
const attrs = valueEl.attributes as any;
|
||||
if (
|
||||
const isProtected =
|
||||
attrs.length > 0 &&
|
||||
attrs.ProtectInMemory != null &&
|
||||
attrs.ProtectInMemory.value === "True"
|
||||
) {
|
||||
type = FieldType.Hidden;
|
||||
attrs.ProtectInMemory.value === "True";
|
||||
|
||||
if (isProtected) {
|
||||
// Protected fields should always be imported as hidden fields,
|
||||
// regardless of length or newlines (fixes #16897)
|
||||
if (cipher.fields == null) {
|
||||
cipher.fields = [];
|
||||
}
|
||||
const field = new FieldView();
|
||||
field.type = FieldType.Hidden;
|
||||
field.name = key;
|
||||
field.value = value;
|
||||
cipher.fields.push(field);
|
||||
} else {
|
||||
this.processKvp(cipher, key, value, FieldType.Text);
|
||||
}
|
||||
this.processKvp(cipher, key, value, type);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ export class RoboFormCsvImporter extends BaseImporter implements Importer {
|
||||
cipher.notes = this.getValueOrDefault(value.Note);
|
||||
cipher.name = this.getValueOrDefault(value.Name, "--");
|
||||
cipher.login.username = this.getValueOrDefault(value.Login);
|
||||
cipher.login.password = this.getValueOrDefault(value.Pwd);
|
||||
cipher.login.uris = this.makeUriArray(value.Url);
|
||||
cipher.login.password =
|
||||
this.getValueOrDefault(value.Pwd) ?? this.getValueOrDefault(value.Password);
|
||||
cipher.login.uris = this.makeUriArray(value.Url) ?? this.makeUriArray(value.URL);
|
||||
|
||||
if (!this.isNullOrWhitespace(value.Rf_fields)) {
|
||||
this.parseRfFields(cipher, value);
|
||||
|
||||
@@ -354,6 +354,57 @@ line2</Value>
|
||||
</Group>
|
||||
<DeletedObjects />
|
||||
</KeePassFile>`;
|
||||
export const TestDataWithProtectedFields = `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<KeePassFile>
|
||||
<Root>
|
||||
<Group>
|
||||
<UUID>KvS57lVwl13AfGFLwkvq4Q==</UUID>
|
||||
<Name>Root</Name>
|
||||
<Entry>
|
||||
<UUID>fAa543oYlgnJKkhKag5HLw==</UUID>
|
||||
<String>
|
||||
<Key>Title</Key>
|
||||
<Value>Test Entry</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>UserName</Key>
|
||||
<Value>testuser</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>Password</Key>
|
||||
<Value ProtectInMemory="True">testpass</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>URL</Key>
|
||||
<Value>https://example.com</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>Notes</Key>
|
||||
<Value>Regular notes</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>SAFE UN-LOCKING instructions</Key>
|
||||
<Value ProtectInMemory="True">Secret instructions here</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>CustomField</Key>
|
||||
<Value>Custom value</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>LongProtectedField</Key>
|
||||
<Value ProtectInMemory="True">This is a very long protected field value that exceeds 200 characters. It contains sensitive information that should be imported as a hidden field and not appended to the notes section. This text is long enough to trigger the old behavior.</Value>
|
||||
</String>
|
||||
<String>
|
||||
<Key>MultilineProtectedField</Key>
|
||||
<Value ProtectInMemory="True">Line 1
|
||||
Line 2
|
||||
Line 3</Value>
|
||||
</String>
|
||||
</Entry>
|
||||
</Group>
|
||||
</Root>
|
||||
</KeePassFile>`;
|
||||
|
||||
export const TestData2 = `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<Meta>
|
||||
<Generator>KeePass</Generator>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
|
||||
@@ -267,7 +268,7 @@ export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService
|
||||
private async getRpIdForUser(userId: UserId): Promise<string | undefined> {
|
||||
try {
|
||||
const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId));
|
||||
const hostname = environment.getHostname();
|
||||
const hostname = Utils.getHost(environment.getWebVaultUrl());
|
||||
|
||||
// The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host.
|
||||
if (!hostname) {
|
||||
|
||||
@@ -179,18 +179,36 @@ describe("BiometricStateService", () => {
|
||||
});
|
||||
|
||||
describe("biometricUnlockEnabled$", () => {
|
||||
it("emits when biometricUnlockEnabled state is updated", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(true);
|
||||
describe("no user id provided, active user", () => {
|
||||
it("emits when biometricUnlockEnabled state is updated", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(true);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true);
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true);
|
||||
});
|
||||
|
||||
it("emits false when biometricUnlockEnabled state is undefined", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when biometricUnlockEnabled state is undefined", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(undefined as unknown as boolean);
|
||||
describe("user id provided", () => {
|
||||
it("returns biometricUnlockEnabled state for the given user", async () => {
|
||||
stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false);
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the state is not set", async () => {
|
||||
stateProvider.singleUser
|
||||
.getFake(userId, BIOMETRIC_UNLOCK_ENABLED)
|
||||
.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +216,7 @@ describe("BiometricStateService", () => {
|
||||
it("updates biometricUnlockEnabled$", async () => {
|
||||
await sut.setBiometricUnlockEnabled(true);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true);
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true);
|
||||
});
|
||||
|
||||
it("updates state", async () => {
|
||||
@@ -210,22 +228,6 @@ describe("BiometricStateService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBiometricUnlockEnabled", () => {
|
||||
it("returns biometricUnlockEnabled state for the given user", async () => {
|
||||
stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true);
|
||||
|
||||
expect(await sut.getBiometricUnlockEnabled(userId)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the state is not set", async () => {
|
||||
stateProvider.singleUser
|
||||
.getFake(userId, BIOMETRIC_UNLOCK_ENABLED)
|
||||
.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFingerprintValidated", () => {
|
||||
it("updates fingerprintValidated$", async () => {
|
||||
await sut.setFingerprintValidated(true);
|
||||
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
|
||||
export abstract class BiometricStateService {
|
||||
/**
|
||||
* `true` if the currently active user has elected to store a biometric key to unlock their vault.
|
||||
* Returns whether biometric unlock is enabled for a user.
|
||||
* @param userId The user id to check. If not provided, returns the state for the currently active user.
|
||||
* @returns An observable that emits `true` if the user has elected to store a biometric key to unlock their vault.
|
||||
*/
|
||||
abstract biometricUnlockEnabled$: Observable<boolean>; // used to be biometricUnlock
|
||||
abstract biometricUnlockEnabled$(userId?: UserId): Observable<boolean>;
|
||||
/**
|
||||
* If the user has elected to require a password on first unlock of an application instance, this key will store the
|
||||
* encrypted client key half used to unlock the vault.
|
||||
@@ -53,6 +55,7 @@ export abstract class BiometricStateService {
|
||||
|
||||
/**
|
||||
* Gets the biometric unlock enabled state for the given user.
|
||||
* @deprecated Use {@link biometricUnlockEnabled$} instead
|
||||
* @param userId user Id to check
|
||||
*/
|
||||
abstract getBiometricUnlockEnabled(userId: UserId): Promise<boolean>;
|
||||
@@ -103,7 +106,6 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
private promptAutomaticallyState: ActiveUserState<boolean>;
|
||||
private fingerprintValidatedState: GlobalState<boolean>;
|
||||
private lastProcessReloadState: GlobalState<Date>;
|
||||
biometricUnlockEnabled$: Observable<boolean>;
|
||||
encryptedClientKeyHalf$: Observable<EncString | null>;
|
||||
promptCancelled$: Observable<boolean>;
|
||||
promptAutomatically$: Observable<boolean>;
|
||||
@@ -112,7 +114,6 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED);
|
||||
this.biometricUnlockEnabled$ = this.biometricUnlockEnabledState.state$.pipe(map(Boolean));
|
||||
|
||||
this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF);
|
||||
this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe(
|
||||
@@ -142,6 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
await this.biometricUnlockEnabledState.update(() => enabled);
|
||||
}
|
||||
|
||||
biometricUnlockEnabled$(userId?: UserId): Observable<boolean> {
|
||||
if (userId != null) {
|
||||
return this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean));
|
||||
}
|
||||
// Backwards compatibility for active user state
|
||||
// TODO remove with https://bitwarden.atlassian.net/browse/PM-12043
|
||||
return this.biometricUnlockEnabledState.state$.pipe(map(Boolean));
|
||||
}
|
||||
|
||||
async getBiometricUnlockEnabled(userId: UserId): Promise<boolean> {
|
||||
return await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)),
|
||||
|
||||
Reference in New Issue
Block a user