mirror of
https://github.com/bitwarden/browser
synced 2026-02-14 07:23:45 +00:00
Merge remote-tracking branch 'origin/main' into uif/CL-1009/button-style-updates
This commit is contained in:
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -84,6 +84,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev
|
||||
libs/angular/src/billing @bitwarden/team-billing-dev
|
||||
libs/common/src/billing @bitwarden/team-billing-dev
|
||||
libs/billing @bitwarden/team-billing-dev
|
||||
libs/pricing @bitwarden/team-billing-dev
|
||||
bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev
|
||||
|
||||
## Platform team files ##
|
||||
@@ -227,7 +228,9 @@ apps/web/src/locales/en/messages.json
|
||||
**/tsconfig.json @bitwarden/team-platform-dev
|
||||
**/jest.config.js @bitwarden/team-platform-dev
|
||||
**/project.jsons @bitwarden/team-platform-dev
|
||||
libs/pricing @bitwarden/team-billing-dev
|
||||
# Platform override specifically for the package-lock.json in
|
||||
# native-messaging-test-runner so that Platform can manage all lock file updates
|
||||
apps/desktop/native-messaging-test-runner/package-lock.json @bitwarden/team-platform-dev
|
||||
|
||||
# Claude related files
|
||||
.claude/ @bitwarden/team-ai-sme
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
run: cargo +nightly udeps --workspace --all-features --all-targets
|
||||
|
||||
- name: Install cargo-deny
|
||||
uses: taiki-e/install-action@2e9d707ef49c9b094d45955b60c7e5c0dfedeb14 # v2.66.5
|
||||
uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7
|
||||
with:
|
||||
tool: cargo-deny@0.18.6
|
||||
|
||||
|
||||
@@ -5001,6 +5001,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadAttachmentLabel": {
|
||||
"message": "Download Attachment"
|
||||
},
|
||||
"downloadBitwarden": {
|
||||
"message": "Download Bitwarden"
|
||||
},
|
||||
|
||||
@@ -257,7 +257,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
pin: await this.pinService.isPinSet(activeAccount.id),
|
||||
pinLockWithMasterPassword:
|
||||
(await this.pinService.getPinLockType(activeAccount.id)) == "EPHEMERAL",
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id),
|
||||
enableAutoBiometricsPrompt: await firstValueFrom(
|
||||
this.biometricStateService.promptAutomatically$,
|
||||
),
|
||||
|
||||
@@ -767,7 +767,6 @@ describe("NotificationBackground", () => {
|
||||
let createWithServerSpy: jest.SpyInstance;
|
||||
let updateWithServerSpy: jest.SpyInstance;
|
||||
let folderExistsSpy: jest.SpyInstance;
|
||||
let cipherEncryptSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
@@ -791,7 +790,6 @@ describe("NotificationBackground", () => {
|
||||
createWithServerSpy = jest.spyOn(cipherService, "createWithServer");
|
||||
updateWithServerSpy = jest.spyOn(cipherService, "updateWithServer");
|
||||
folderExistsSpy = jest.spyOn(notificationBackground as any, "folderExists");
|
||||
cipherEncryptSpy = jest.spyOn(cipherService, "encrypt");
|
||||
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
});
|
||||
@@ -1190,13 +1188,7 @@ describe("NotificationBackground", () => {
|
||||
folderExistsSpy.mockResolvedValueOnce(false);
|
||||
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
|
||||
editItemSpy.mockResolvedValueOnce(undefined);
|
||||
cipherEncryptSpy.mockResolvedValueOnce({
|
||||
cipher: {
|
||||
...cipherView,
|
||||
id: "testId",
|
||||
},
|
||||
encryptedFor: userId,
|
||||
});
|
||||
createWithServerSpy.mockResolvedValueOnce(cipherView);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
@@ -1205,7 +1197,6 @@ describe("NotificationBackground", () => {
|
||||
queueMessage,
|
||||
null,
|
||||
);
|
||||
expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId");
|
||||
expect(createWithServerSpy).toHaveBeenCalled();
|
||||
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
|
||||
sender.tab,
|
||||
@@ -1241,13 +1232,6 @@ describe("NotificationBackground", () => {
|
||||
folderExistsSpy.mockResolvedValueOnce(true);
|
||||
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
|
||||
editItemSpy.mockResolvedValueOnce(undefined);
|
||||
cipherEncryptSpy.mockResolvedValueOnce({
|
||||
cipher: {
|
||||
...cipherView,
|
||||
id: "testId",
|
||||
},
|
||||
encryptedFor: userId,
|
||||
});
|
||||
const errorMessage = "fetch error";
|
||||
createWithServerSpy.mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
@@ -1256,7 +1240,6 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId");
|
||||
expect(createWithServerSpy).toThrow(errorMessage);
|
||||
expect(tabSendMessageSpy).not.toHaveBeenCalledWith(sender.tab, {
|
||||
command: "addedCipher",
|
||||
|
||||
@@ -866,13 +866,11 @@ export default class NotificationBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = await this.cipherService.encrypt(newCipher, activeUserId);
|
||||
const { cipher } = encrypted;
|
||||
try {
|
||||
await this.cipherService.createWithServer(encrypted);
|
||||
const resultCipher = await this.cipherService.createWithServer(newCipher, activeUserId);
|
||||
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
|
||||
itemName: newCipher?.name && String(newCipher?.name),
|
||||
cipherId: cipher?.id && String(cipher?.id),
|
||||
cipherId: resultCipher?.id && String(resultCipher?.id),
|
||||
});
|
||||
await BrowserApi.tabSendMessage(tab, { command: "addedCipher" });
|
||||
} catch (error) {
|
||||
@@ -910,7 +908,6 @@ export default class NotificationBackground {
|
||||
await BrowserApi.tabSendMessage(tab, { command: "editedCipher" });
|
||||
return;
|
||||
}
|
||||
const cipher = await this.cipherService.encrypt(cipherView, userId);
|
||||
|
||||
try {
|
||||
if (!cipherView.edit) {
|
||||
@@ -939,7 +936,7 @@ export default class NotificationBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cipherService.updateWithServer(cipher);
|
||||
await this.cipherService.updateWithServer(cipherView, userId);
|
||||
|
||||
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
|
||||
itemName: cipherView?.name && String(cipherView?.name),
|
||||
|
||||
@@ -444,10 +444,9 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
this.buildCipher(name, username);
|
||||
const encrypted = await this.cipherService.encrypt(this.cipher, activeUserId);
|
||||
try {
|
||||
await this.cipherService.createWithServer(encrypted);
|
||||
this.cipher.id = encrypted.cipher.id;
|
||||
const result = await this.cipherService.createWithServer(this.cipher, activeUserId);
|
||||
this.cipher.id = result.id;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service"
|
||||
import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
|
||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
@@ -211,6 +212,7 @@ import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
} from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
|
||||
@@ -367,6 +369,7 @@ export default class MainBackground {
|
||||
apiService: ApiServiceAbstraction;
|
||||
hibpApiService: HibpApiService;
|
||||
environmentService: BrowserEnvironmentService;
|
||||
cipherSdkService: CipherSdkService;
|
||||
cipherService: CipherServiceAbstraction;
|
||||
folderService: InternalFolderServiceAbstraction;
|
||||
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction;
|
||||
@@ -973,6 +976,8 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService);
|
||||
|
||||
this.cipherService = new CipherService(
|
||||
this.keyService,
|
||||
this.domainSettingsService,
|
||||
@@ -988,6 +993,7 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
this.cipherSdkService,
|
||||
);
|
||||
this.folderService = new FolderService(
|
||||
this.keyService,
|
||||
|
||||
@@ -7,6 +7,8 @@ export type PhishingResource = {
|
||||
todayUrl: string;
|
||||
/** Matcher used to decide whether a given URL matches an entry from this resource */
|
||||
match: (url: URL, entry: string) => boolean;
|
||||
/** Whether to use the custom matcher. If false, only exact hasUrl lookups are used. Default: true */
|
||||
useCustomMatcher?: boolean;
|
||||
};
|
||||
|
||||
export const PhishingResourceType = Object.freeze({
|
||||
@@ -56,6 +58,8 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
|
||||
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-links-ACTIVE.txt.md5",
|
||||
todayUrl:
|
||||
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-links-NEW-today.txt",
|
||||
// Disabled for performance - cursor search takes 6+ minutes on large databases
|
||||
useCustomMatcher: false,
|
||||
match: (url: URL, entry: string) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("PhishingDataService", () => {
|
||||
// Set default mock behaviors
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue([]);
|
||||
mockIndexedDbService.findMatchingUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.saveUrls.mockResolvedValue(undefined);
|
||||
mockIndexedDbService.addUrls.mockResolvedValue(undefined);
|
||||
mockIndexedDbService.saveUrlsFromStream.mockResolvedValue(undefined);
|
||||
@@ -90,7 +91,7 @@ describe("PhishingDataService", () => {
|
||||
|
||||
it("should NOT detect QA test addresses - different subpath", async () => {
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue([]);
|
||||
mockIndexedDbService.findMatchingUrl.mockResolvedValue(false);
|
||||
|
||||
const url = new URL("https://phishing.testcategory.com/other");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
@@ -120,70 +121,65 @@ describe("PhishingDataService", () => {
|
||||
expect(result).toBe(true);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/testing-param");
|
||||
// Should not fall back to custom matcher when hasUrl returns true
|
||||
expect(mockIndexedDbService.loadAllUrls).not.toHaveBeenCalled();
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fall back to custom matcher when hasUrl returns false", async () => {
|
||||
it("should return false when hasUrl returns false (custom matcher disabled)", async () => {
|
||||
// Mock hasUrl to return false (no direct href match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return phishing URLs for custom matcher
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/path"]);
|
||||
|
||||
const url = new URL("http://phish.com/path");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Custom matcher is currently disabled (useCustomMatcher: false), so result is false
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/path");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
// Custom matcher should NOT be called since it's disabled
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not detect a safe web address", async () => {
|
||||
// Mock hasUrl to return false
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return phishing URLs that don't match
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com", "http://badguy.net"]);
|
||||
|
||||
const url = new URL("http://safe.com");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://safe.com/");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
// Custom matcher is disabled, so findMatchingUrl should NOT be called
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not match against root web address with subpaths using custom matcher", async () => {
|
||||
it("should not match against root web address with subpaths (custom matcher disabled)", async () => {
|
||||
// Mock hasUrl to return false (no direct href match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return entry that matches with subpath
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login"]);
|
||||
|
||||
const url = new URL("http://phish.com/login/page");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
// Custom matcher is disabled, so findMatchingUrl should NOT be called
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not match against root web address with different subpaths using custom matcher", async () => {
|
||||
it("should not match against root web address with different subpaths (custom matcher disabled)", async () => {
|
||||
// Mock hasUrl to return false (no direct hostname match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return entry that matches with subpath
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login/page1"]);
|
||||
|
||||
const url = new URL("http://phish.com/login/page2");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page2");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
// Custom matcher is disabled, so findMatchingUrl should NOT be called
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle IndexedDB errors gracefully", async () => {
|
||||
// Mock hasUrl to throw error
|
||||
mockIndexedDbService.hasUrl.mockRejectedValue(new Error("hasUrl error"));
|
||||
// Mock loadAllUrls to also throw error
|
||||
mockIndexedDbService.loadAllUrls.mockRejectedValue(new Error("IndexedDB error"));
|
||||
|
||||
const url = new URL("http://phish.com/about");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
@@ -193,10 +189,8 @@ describe("PhishingDataService", () => {
|
||||
"[PhishingDataService] IndexedDB lookup via hasUrl failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingDataService] Error running custom matcher",
|
||||
expect.any(Error),
|
||||
);
|
||||
// Custom matcher is disabled, so no custom matcher error is expected
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -153,8 +153,18 @@ export class PhishingDataService {
|
||||
* @returns True if the URL is a known phishing web address, false otherwise
|
||||
*/
|
||||
async isPhishingWebAddress(url: URL): Promise<boolean> {
|
||||
this.logService.debug("[PhishingDataService] isPhishingWebAddress called for: " + url.href);
|
||||
|
||||
// Skip non-http(s) protocols - phishing database only contains web URLs
|
||||
// This prevents expensive fallback checks for chrome://, about:, file://, etc.
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
this.logService.debug("[PhishingDataService] Skipping non-http(s) protocol: " + url.protocol);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check for QA/dev test addresses
|
||||
if (this._testWebAddresses.includes(url.href)) {
|
||||
this.logService.info("[PhishingDataService] Found test web address: " + url.href);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -162,28 +172,73 @@ export class PhishingDataService {
|
||||
|
||||
try {
|
||||
// Quick lookup: check direct presence of href in IndexedDB
|
||||
const hasUrl = await this.indexedDbService.hasUrl(url.href);
|
||||
// Also check without trailing slash since browsers add it but DB entries may not have it
|
||||
const urlHref = url.href;
|
||||
const urlWithoutTrailingSlash = urlHref.endsWith("/") ? urlHref.slice(0, -1) : null;
|
||||
|
||||
this.logService.debug("[PhishingDataService] Checking hasUrl on this string: " + urlHref);
|
||||
let hasUrl = await this.indexedDbService.hasUrl(urlHref);
|
||||
|
||||
// If not found and URL has trailing slash, try without it
|
||||
if (!hasUrl && urlWithoutTrailingSlash) {
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Checking hasUrl without trailing slash: " +
|
||||
urlWithoutTrailingSlash,
|
||||
);
|
||||
hasUrl = await this.indexedDbService.hasUrl(urlWithoutTrailingSlash);
|
||||
}
|
||||
|
||||
if (hasUrl) {
|
||||
this.logService.info(
|
||||
"[PhishingDataService] Found phishing web address through direct lookup: " + urlHref,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err);
|
||||
}
|
||||
|
||||
// If a custom matcher is provided, iterate stored entries and apply the matcher.
|
||||
if (resource && resource.match) {
|
||||
// If a custom matcher is provided and enabled, use cursor-based search.
|
||||
// This avoids loading all URLs into memory and allows early exit on first match.
|
||||
// Can be disabled via useCustomMatcher: false for performance reasons.
|
||||
if (resource && resource.match && resource.useCustomMatcher !== false) {
|
||||
try {
|
||||
const entries = await this.indexedDbService.loadAllUrls();
|
||||
for (const entry of entries) {
|
||||
if (resource.match(url, entry)) {
|
||||
return true;
|
||||
}
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Starting cursor-based search for: " + url.href,
|
||||
);
|
||||
const startTime = performance.now();
|
||||
|
||||
const found = await this.indexedDbService.findMatchingUrl((entry) =>
|
||||
resource.match(url, entry),
|
||||
);
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = (endTime - startTime).toFixed(2);
|
||||
this.logService.debug(
|
||||
`[PhishingDataService] Cursor-based search completed in ${duration}ms for: ${url.href} (found: ${found})`,
|
||||
);
|
||||
|
||||
if (found) {
|
||||
this.logService.info(
|
||||
"[PhishingDataService] Found phishing web address through custom matcher: " + url.href,
|
||||
);
|
||||
} else {
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] No match found, returning false for: " + url.href,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Error running custom matcher", err);
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Returning false due to error for: " + url.href,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] No custom matcher, returning false for: " + url.href,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
@@ -43,6 +43,7 @@ export class PhishingDetectionService {
|
||||
private static _tabUpdated$ = new Subject<PhishingDetectionNavigationEvent>();
|
||||
private static _ignoredHostnames = new Set<string>();
|
||||
private static _didInit = false;
|
||||
private static _activeSearchCount = 0;
|
||||
|
||||
static initialize(
|
||||
logService: LogService,
|
||||
@@ -63,7 +64,7 @@ export class PhishingDetectionService {
|
||||
tap((message) =>
|
||||
logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`),
|
||||
),
|
||||
concatMap(async (message) => {
|
||||
mergeMap(async (message) => {
|
||||
const url = new URL(message.url);
|
||||
this._ignoredHostnames.add(url.hostname);
|
||||
await BrowserApi.navigateTabToUrl(message.tabId, url);
|
||||
@@ -88,23 +89,40 @@ export class PhishingDetectionService {
|
||||
prev.ignored === curr.ignored,
|
||||
),
|
||||
tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)),
|
||||
concatMap(async ({ tabId, url, ignored }) => {
|
||||
if (ignored) {
|
||||
// The next time this host is visited, block again
|
||||
this._ignoredHostnames.delete(url.hostname);
|
||||
return;
|
||||
}
|
||||
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
|
||||
if (!isPhishing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phishingWarningPage = new URL(
|
||||
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
|
||||
`?phishingUrl=${url.toString()}`,
|
||||
// Use mergeMap for parallel processing - each tab check runs independently
|
||||
// Concurrency limit of 5 prevents overwhelming IndexedDB
|
||||
mergeMap(async ({ tabId, url, ignored }) => {
|
||||
this._activeSearchCount++;
|
||||
const searchId = `${tabId}-${Date.now()}`;
|
||||
logService.debug(
|
||||
`[PhishingDetectionService] Search STARTED [${searchId}] for ${url.href} (active: ${this._activeSearchCount}/5)`,
|
||||
);
|
||||
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
|
||||
}),
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
if (ignored) {
|
||||
// The next time this host is visited, block again
|
||||
this._ignoredHostnames.delete(url.hostname);
|
||||
return;
|
||||
}
|
||||
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
|
||||
if (!isPhishing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phishingWarningPage = new URL(
|
||||
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
|
||||
`?phishingUrl=${url.toString()}`,
|
||||
);
|
||||
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
|
||||
} finally {
|
||||
this._activeSearchCount--;
|
||||
const duration = (performance.now() - startTime).toFixed(2);
|
||||
logService.debug(
|
||||
`[PhishingDetectionService] Search FINISHED [${searchId}] for ${url.href} in ${duration}ms (active: ${this._activeSearchCount}/5)`,
|
||||
);
|
||||
}
|
||||
}, 5),
|
||||
);
|
||||
|
||||
const onCancelCommand$ = messageListener
|
||||
|
||||
@@ -435,6 +435,89 @@ describe("PhishingIndexedDbService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMatchingUrl", () => {
|
||||
it("returns true when matcher finds a match", async () => {
|
||||
mockStore.set("https://example.com", { url: "https://example.com" });
|
||||
mockStore.set("https://phishing.net", { url: "https://phishing.net" });
|
||||
mockStore.set("https://test.org", { url: "https://test.org" });
|
||||
|
||||
const matcher = (url: string) => url.includes("phishing");
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly");
|
||||
expect(mockObjectStore.openCursor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when no URLs match", async () => {
|
||||
mockStore.set("https://example.com", { url: "https://example.com" });
|
||||
mockStore.set("https://test.org", { url: "https://test.org" });
|
||||
|
||||
const matcher = (url: string) => url.includes("notfound");
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when store is empty", async () => {
|
||||
const matcher = (url: string) => url.includes("anything");
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("exits early on first match without iterating all records", async () => {
|
||||
mockStore.set("https://match1.com", { url: "https://match1.com" });
|
||||
mockStore.set("https://match2.com", { url: "https://match2.com" });
|
||||
mockStore.set("https://match3.com", { url: "https://match3.com" });
|
||||
|
||||
const matcherCallCount = jest
|
||||
.fn()
|
||||
.mockImplementation((url: string) => url.includes("match2"));
|
||||
await service.findMatchingUrl(matcherCallCount);
|
||||
|
||||
// Matcher should be called for match1.com and match2.com, but NOT match3.com
|
||||
// because it exits early on first match
|
||||
expect(matcherCallCount).toHaveBeenCalledWith("https://match1.com");
|
||||
expect(matcherCallCount).toHaveBeenCalledWith("https://match2.com");
|
||||
expect(matcherCallCount).not.toHaveBeenCalledWith("https://match3.com");
|
||||
expect(matcherCallCount).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("supports complex matcher logic", async () => {
|
||||
mockStore.set("https://example.com/path", { url: "https://example.com/path" });
|
||||
mockStore.set("https://test.org", { url: "https://test.org" });
|
||||
mockStore.set("https://phishing.net/login", { url: "https://phishing.net/login" });
|
||||
|
||||
const matcher = (url: string) => {
|
||||
return url.includes("phishing") && url.includes("login");
|
||||
};
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const error = new Error("IndexedDB error");
|
||||
mockOpenRequest.error = error;
|
||||
(global.indexedDB.open as jest.Mock).mockImplementation(() => {
|
||||
setTimeout(() => {
|
||||
mockOpenRequest.onerror?.();
|
||||
}, 0);
|
||||
return mockOpenRequest;
|
||||
});
|
||||
|
||||
const matcher = (url: string) => url.includes("test");
|
||||
const result = await service.findMatchingUrl(matcher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingIndexedDbService] Cursor search failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("database initialization", () => {
|
||||
it("creates object store with keyPath on upgrade", async () => {
|
||||
mockDb.objectStoreNames.contains.mockReturnValue(false);
|
||||
|
||||
@@ -195,6 +195,60 @@ export class PhishingIndexedDbService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any URL in the database matches the given matcher function.
|
||||
* Uses a cursor to iterate through records without loading all into memory.
|
||||
* Returns immediately on first match for optimal performance.
|
||||
*
|
||||
* @param matcher - Function that tests each URL and returns true if it matches
|
||||
* @returns `true` if any URL matches, `false` if none match or on error
|
||||
*/
|
||||
async findMatchingUrl(matcher: (url: string) => boolean): Promise<boolean> {
|
||||
this.logService.debug("[PhishingIndexedDbService] Searching for matching URL with cursor...");
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
return await this.cursorSearch(db, matcher);
|
||||
} catch (error) {
|
||||
this.logService.error("[PhishingIndexedDbService] Cursor search failed", error);
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs cursor-based search through all URLs.
|
||||
* Tests each URL with the matcher without accumulating records in memory.
|
||||
*/
|
||||
private cursorSearch(db: IDBDatabase, matcher: (url: string) => boolean): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(this.STORE_NAME, "readonly")
|
||||
.objectStore(this.STORE_NAME)
|
||||
.openCursor();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = (e) => {
|
||||
const cursor = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
|
||||
if (cursor) {
|
||||
const url = (cursor.value as PhishingUrlRecord).url;
|
||||
// Test the URL immediately without accumulating in memory
|
||||
if (matcher(url)) {
|
||||
// Found a match
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
// No match, continue to next record
|
||||
cursor.continue();
|
||||
} else {
|
||||
// Reached end of records without finding a match
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves phishing URLs directly from a stream.
|
||||
* Processes data incrementally to minimize memory usage.
|
||||
|
||||
@@ -35,7 +35,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService {
|
||||
super();
|
||||
// Always connect to the native messaging background if biometrics are enabled, not just when it is used
|
||||
// so that there is no wait when used.
|
||||
const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$;
|
||||
const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$();
|
||||
|
||||
combineLatest([timer(0, this.BACKGROUND_POLLING_INTERVAL), biometricsEnabled])
|
||||
.pipe(
|
||||
|
||||
@@ -375,7 +375,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(
|
||||
mockInputs.platformSupportsSecureStorage,
|
||||
);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(true);
|
||||
biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true));
|
||||
|
||||
// PIN
|
||||
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);
|
||||
@@ -386,6 +386,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
|
||||
|
||||
expect(unlockOptions).toEqual(expectedOutput);
|
||||
expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ export class ExtensionLockComponentService implements LockComponentService {
|
||||
return combineLatest([
|
||||
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
|
||||
defer(async () => {
|
||||
if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$))) {
|
||||
if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId)))) {
|
||||
return BiometricsStatus.NotEnabledLocally;
|
||||
} else {
|
||||
// TODO remove after 2025.3
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<popup-page [loading]="showSpinnerLoaders$ | async" [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-page [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-header slot="header" [pageTitle]="'send' | i18n">
|
||||
<ng-container slot="end">
|
||||
<tools-new-send-dropdown *ngIf="!sendsDisabled"></tools-new-send-dropdown>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -110,7 +109,6 @@ describe("SendV2Component", () => {
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: { hasPremiumFromAnySource$: of(false) },
|
||||
},
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
|
||||
@@ -11,8 +11,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
@@ -84,30 +82,17 @@ export class SendV2Component implements OnDestroy {
|
||||
|
||||
protected listState: SendState | null = null;
|
||||
protected sends$ = this.sendItemsService.filteredAndSortedSends$;
|
||||
private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.VaultLoadingSkeletons,
|
||||
);
|
||||
protected sendsLoading$ = this.sendItemsService.loading$.pipe(
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
/** Spinner Loading State */
|
||||
protected showSpinnerLoaders$ = combineLatest([
|
||||
this.sendsLoading$,
|
||||
this.skeletonFeatureFlag$,
|
||||
]).pipe(map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled));
|
||||
|
||||
/** Skeleton Loading State */
|
||||
protected showSkeletonsLoaders$ = combineLatest([
|
||||
this.sendsLoading$,
|
||||
this.searchService.isSendSearching$,
|
||||
this.skeletonFeatureFlag$,
|
||||
]).pipe(
|
||||
map(
|
||||
([loading, cipherSearching, skeletonsEnabled]) =>
|
||||
(loading || cipherSearching) && skeletonsEnabled,
|
||||
),
|
||||
map(([loading, cipherSearching]) => loading || cipherSearching),
|
||||
distinctUntilChanged(),
|
||||
skeletonLoadingDelay(),
|
||||
);
|
||||
@@ -128,7 +113,6 @@ export class SendV2Component implements OnDestroy {
|
||||
protected sendListFiltersService: SendListFiltersService,
|
||||
private policyService: PolicyService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private searchService: SearchService,
|
||||
) {
|
||||
combineLatest([
|
||||
|
||||
@@ -277,8 +277,7 @@ export class ItemMoreOptionsComponent {
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
)) as UserId;
|
||||
|
||||
const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(encryptedCipher);
|
||||
await this.cipherService.updateWithServer(cipher, activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormsModule } from "@angular/forms";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
@@ -20,7 +19,6 @@ describe("VaultV2SearchComponent", () => {
|
||||
|
||||
const searchText$ = new BehaviorSubject("");
|
||||
const loading$ = new BehaviorSubject(false);
|
||||
const featureFlag$ = new BehaviorSubject(true);
|
||||
const applyFilter = jest.fn();
|
||||
|
||||
const createComponent = () => {
|
||||
@@ -31,7 +29,6 @@ describe("VaultV2SearchComponent", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
applyFilter.mockClear();
|
||||
featureFlag$.next(true);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule],
|
||||
@@ -49,12 +46,6 @@ describe("VaultV2SearchComponent", () => {
|
||||
loading$,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getFeatureFlag$: jest.fn(() => featureFlag$),
|
||||
},
|
||||
},
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
}).compileComponents();
|
||||
@@ -70,91 +61,55 @@ describe("VaultV2SearchComponent", () => {
|
||||
});
|
||||
|
||||
describe("debouncing behavior", () => {
|
||||
describe("when feature flag is enabled", () => {
|
||||
beforeEach(() => {
|
||||
featureFlag$.next(true);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it("debounces search text changes when not loading", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("should not debounce search text changes when loading", fakeAsync(() => {
|
||||
loading$.next(true);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(0);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("cancels previous debounce when new text is entered", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
component.searchText = "test2";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test2");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
describe("when feature flag is disabled", () => {
|
||||
beforeEach(() => {
|
||||
featureFlag$.next(false);
|
||||
createComponent();
|
||||
});
|
||||
it("debounces search text changes when not loading", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
it("debounces search text changes", fakeAsync(() => {
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
tick(SearchTextDebounceInterval);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("ignores loading state and always debounces", fakeAsync(() => {
|
||||
loading$.next(true);
|
||||
it("should not debounce search text changes when loading", fakeAsync(() => {
|
||||
loading$.next(true);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
tick(0);
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
});
|
||||
it("cancels previous debounce when new text is entered", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
component.searchText = "test2";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test2");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,17 +7,13 @@ import {
|
||||
Subscription,
|
||||
combineLatest,
|
||||
debounce,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
switchMap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
|
||||
@@ -40,7 +36,6 @@ export class VaultV2SearchComponent {
|
||||
constructor(
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private vaultPopupLoadingService: VaultPopupLoadingService,
|
||||
private configService: ConfigService,
|
||||
private ngZone: NgZone,
|
||||
) {
|
||||
this.subscribeToLatestSearchText();
|
||||
@@ -63,31 +58,19 @@ export class VaultV2SearchComponent {
|
||||
}
|
||||
|
||||
subscribeToApplyFilter(): void {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons)
|
||||
combineLatest([this.searchText$, this.loading$])
|
||||
.pipe(
|
||||
switchMap((enabled) => {
|
||||
if (!enabled) {
|
||||
return this.searchText$.pipe(
|
||||
debounceTime(SearchTextDebounceInterval),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
return combineLatest([this.searchText$, this.loading$]).pipe(
|
||||
debounce(([_, isLoading]) => {
|
||||
// If loading apply immediately to avoid stale searches.
|
||||
// After loading completes, debounce to avoid excessive searches.
|
||||
const delayTime = isLoading ? 0 : SearchTextDebounceInterval;
|
||||
return timer(delayTime);
|
||||
}),
|
||||
distinctUntilChanged(
|
||||
([prevText, prevLoading], [newText, newLoading]) =>
|
||||
prevText === newText && prevLoading === newLoading,
|
||||
),
|
||||
map(([text, _]) => text),
|
||||
);
|
||||
debounce(([_, isLoading]) => {
|
||||
// If loading apply immediately to avoid stale searches.
|
||||
// After loading completes, debounce to avoid excessive searches.
|
||||
const delayTime = isLoading ? 0 : SearchTextDebounceInterval;
|
||||
return timer(delayTime);
|
||||
}),
|
||||
distinctUntilChanged(
|
||||
([prevText, prevLoading], [newText, newLoading]) =>
|
||||
prevText === newText && prevLoading === newLoading,
|
||||
),
|
||||
map(([text, _]) => text),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((text) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<popup-page [loading]="showSpinnerLoaders$ | async" [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-page [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-header slot="header" [pageTitle]="'vault' | i18n">
|
||||
<ng-container slot="end">
|
||||
<app-new-item-dropdown [initialValues]="newItemItemValues$ | async"></app-new-item-dropdown>
|
||||
@@ -8,37 +8,28 @@
|
||||
</ng-container>
|
||||
</popup-header>
|
||||
|
||||
<ng-template #emptyVaultTemplate>
|
||||
<div
|
||||
*ngIf="vaultState === VaultStateEnum.Empty"
|
||||
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
|
||||
>
|
||||
<bit-no-items [icon]="vaultIcon">
|
||||
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
|
||||
<ng-container slot="description">
|
||||
<p bitTypography="body2" class="tw-mx-6 tw-mt-2">
|
||||
{{ "emptyVaultDescription" | i18n }}
|
||||
</p>
|
||||
</ng-container>
|
||||
<a
|
||||
slot="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
[routerLink]="['/add-cipher']"
|
||||
[queryParams]="{ prefillNameAndURIFromTab: true }"
|
||||
>
|
||||
{{ "newLogin" | i18n }}
|
||||
</a>
|
||||
</bit-no-items>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@if (skeletonFeatureFlag$ | async) {
|
||||
<vault-fade-in-out *ngIf="vaultState === VaultStateEnum.Empty">
|
||||
<ng-container *ngTemplateOutlet="emptyVaultTemplate"></ng-container>
|
||||
@if (vaultState === VaultStateEnum.Empty) {
|
||||
<vault-fade-in-out>
|
||||
<div class="tw-flex tw-flex-col tw-h-full tw-justify-center">
|
||||
<bit-no-items [icon]="vaultIcon">
|
||||
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
|
||||
<ng-container slot="description">
|
||||
<p bitTypography="body2" class="tw-mx-6 tw-mt-2">
|
||||
{{ "emptyVaultDescription" | i18n }}
|
||||
</p>
|
||||
</ng-container>
|
||||
<a
|
||||
slot="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
[routerLink]="['/add-cipher']"
|
||||
[queryParams]="{ prefillNameAndURIFromTab: true }"
|
||||
>
|
||||
{{ "newLogin" | i18n }}
|
||||
</a>
|
||||
</bit-no-items>
|
||||
</div>
|
||||
</vault-fade-in-out>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="emptyVaultTemplate"></ng-container>
|
||||
}
|
||||
|
||||
<blocked-injection-banner
|
||||
@@ -113,31 +104,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #vaultContentTemplate>
|
||||
<ng-container *ngIf="vaultState === null && !(loading$ | async)">
|
||||
<app-autofill-vault-list-items></app-autofill-vault-list-items>
|
||||
<app-vault-list-items-container
|
||||
[title]="'favorites' | i18n"
|
||||
[ciphers]="(favoriteCiphers$ | async) || []"
|
||||
id="favorites"
|
||||
collapsibleKey="favorites"
|
||||
></app-vault-list-items-container>
|
||||
<app-vault-list-items-container
|
||||
[title]="'allItems' | i18n"
|
||||
[ciphers]="(remainingCiphers$ | async) || []"
|
||||
id="allItems"
|
||||
disableSectionMargin
|
||||
collapsibleKey="allItems"
|
||||
></app-vault-list-items-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@if (skeletonFeatureFlag$ | async) {
|
||||
<vault-fade-in-out *ngIf="vaultState === null">
|
||||
<ng-container *ngTemplateOutlet="vaultContentTemplate"></ng-container>
|
||||
@if (vaultState === null) {
|
||||
<vault-fade-in-out>
|
||||
@if (!(loading$ | async)) {
|
||||
<app-autofill-vault-list-items></app-autofill-vault-list-items>
|
||||
<app-vault-list-items-container
|
||||
[title]="'favorites' | i18n"
|
||||
[ciphers]="(favoriteCiphers$ | async) || []"
|
||||
id="favorites"
|
||||
collapsibleKey="favorites"
|
||||
></app-vault-list-items-container>
|
||||
<app-vault-list-items-container
|
||||
[title]="'allItems' | i18n"
|
||||
[ciphers]="(remainingCiphers$ | async) || []"
|
||||
id="allItems"
|
||||
disableSectionMargin
|
||||
collapsibleKey="allItems"
|
||||
></app-vault-list-items-container>
|
||||
}
|
||||
</vault-fade-in-out>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="vaultContentTemplate"></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { provideNoopAnimations } from "@angular/platform-browser/animations";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
@@ -243,6 +244,7 @@ describe("VaultV2Component", () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultV2Component, RouterTestingModule],
|
||||
providers: [
|
||||
provideNoopAnimations(),
|
||||
{ provide: VaultPopupItemsService, useValue: itemsSvc },
|
||||
{ provide: VaultPopupListFiltersService, useValue: filtersSvc },
|
||||
{ provide: VaultPopupScrollPositionService, useValue: scrollSvc },
|
||||
|
||||
@@ -158,10 +158,6 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
}),
|
||||
);
|
||||
|
||||
protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.VaultLoadingSkeletons,
|
||||
);
|
||||
|
||||
protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.BrowserPremiumSpotlight,
|
||||
);
|
||||
@@ -216,20 +212,14 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
PremiumUpgradeDialogComponent.open(this.dialogService);
|
||||
}
|
||||
|
||||
/** When true, show spinner loading state */
|
||||
protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
|
||||
map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled),
|
||||
);
|
||||
|
||||
/** When true, show skeleton loading state with debouncing to prevent flicker */
|
||||
protected showSkeletonsLoaders$ = combineLatest([
|
||||
this.loading$,
|
||||
this.searchService.isCipherSearching$,
|
||||
this.vaultItemsTransferService.transferInProgress$,
|
||||
this.skeletonFeatureFlag$,
|
||||
]).pipe(
|
||||
map(([loading, cipherSearching, transferInProgress, skeletonsEnabled]) => {
|
||||
return (loading || cipherSearching || transferInProgress) && skeletonsEnabled;
|
||||
map(([loading, cipherSearching, transferInProgress]) => {
|
||||
return loading || cipherSearching || transferInProgress;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
skeletonLoadingDelay(),
|
||||
|
||||
@@ -378,8 +378,7 @@ describe("VaultPopupAutofillService", () => {
|
||||
expect(result).toBe(true);
|
||||
expect(mockCipher.login.uris).toHaveLength(1);
|
||||
expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url);
|
||||
expect(mockCipherService.encrypt).toHaveBeenCalledWith(mockCipher, mockUserId);
|
||||
expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockEncryptedCipher);
|
||||
expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockCipher, mockUserId);
|
||||
});
|
||||
|
||||
it("should add a URI to the cipher when there are no existing URIs", async () => {
|
||||
|
||||
@@ -426,8 +426,7 @@ export class VaultPopupAutofillService {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(encCipher);
|
||||
await this.cipherService.updateWithServer(cipher, activeUserId);
|
||||
this.messagingService.send("editedCipher");
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@@ -138,10 +138,8 @@ export class EditCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const encCipher = await this.cipherService.encrypt(cipherView, activeUserId);
|
||||
try {
|
||||
const updatedCipher = await this.cipherService.updateWithServer(encCipher);
|
||||
const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId);
|
||||
const decCipher = await this.cipherService.updateWithServer(cipherView, activeUserId);
|
||||
const res = new CipherResponse(decCipher);
|
||||
return Response.success(res);
|
||||
} catch (e) {
|
||||
|
||||
@@ -147,11 +147,13 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service"
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
|
||||
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
} from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
@@ -254,6 +256,7 @@ export class ServiceContainer {
|
||||
twoFactorApiService: TwoFactorApiService;
|
||||
hibpApiService: HibpApiService;
|
||||
environmentService: EnvironmentService;
|
||||
cipherSdkService: CipherSdkService;
|
||||
cipherService: CipherService;
|
||||
folderService: InternalFolderService;
|
||||
organizationUserApiService: OrganizationUserApiService;
|
||||
@@ -794,6 +797,8 @@ export class ServiceContainer {
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService);
|
||||
|
||||
this.cipherService = new CipherService(
|
||||
this.keyService,
|
||||
this.domainSettingsService,
|
||||
@@ -809,6 +814,7 @@ export class ServiceContainer {
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
this.cipherSdkService,
|
||||
);
|
||||
|
||||
this.cipherArchiveService = new DefaultCipherArchiveService(
|
||||
|
||||
@@ -103,10 +103,11 @@ export class CreateCommand {
|
||||
return Response.error("Creating this item type is restricted by organizational policy.");
|
||||
}
|
||||
|
||||
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
|
||||
const newCipher = await this.cipherService.createWithServer(cipher);
|
||||
const decCipher = await this.cipherService.decrypt(newCipher, activeUserId);
|
||||
const res = new CipherResponse(decCipher);
|
||||
const newCipher = await this.cipherService.createWithServer(
|
||||
CipherExport.toView(req),
|
||||
activeUserId,
|
||||
);
|
||||
const res = new CipherResponse(newCipher);
|
||||
return Response.success(res);
|
||||
} catch (e) {
|
||||
return Response.error(e);
|
||||
|
||||
16
apps/desktop/desktop_native/Cargo.lock
generated
16
apps/desktop/desktop_native/Cargo.lock
generated
@@ -3350,9 +3350,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
@@ -3361,9 +3361,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.28"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3372,9 +3372,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.33"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
@@ -3405,9 +3405,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
|
||||
@@ -65,8 +65,8 @@ sysinfo = "=0.37.2"
|
||||
thiserror = "=2.0.17"
|
||||
tokio = "=1.48.0"
|
||||
tokio-util = "=0.7.17"
|
||||
tracing = "=0.1.41"
|
||||
tracing-subscriber = { version = "=0.3.20", features = [
|
||||
tracing = "=0.1.44"
|
||||
tracing-subscriber = { version = "=0.3.22", features = [
|
||||
"fmt",
|
||||
"env-filter",
|
||||
"tracing-log",
|
||||
|
||||
@@ -385,7 +385,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id),
|
||||
),
|
||||
pin: this.userHasPinSet,
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id),
|
||||
requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey(
|
||||
activeAccount.id,
|
||||
)),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LoginEmailService,
|
||||
SsoUrlService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
@@ -123,6 +125,8 @@ import {
|
||||
import {
|
||||
LockComponentService,
|
||||
SessionTimeoutSettingsComponentService,
|
||||
WebAuthnPrfUnlockService,
|
||||
DefaultWebAuthnPrfUnlockService,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
|
||||
import {
|
||||
@@ -413,6 +417,21 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DesktopLockComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebAuthnPrfUnlockService,
|
||||
useClass: DefaultWebAuthnPrfUnlockService,
|
||||
deps: [
|
||||
WebAuthnLoginPrfKeyServiceAbstraction,
|
||||
KeyServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
EncryptService,
|
||||
EnvironmentService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
WINDOW,
|
||||
LogServiceAbstraction,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CLIENT_TYPE,
|
||||
useValue: ClientType.Desktop,
|
||||
|
||||
@@ -299,12 +299,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
throw new Error("No active user ID found!");
|
||||
}
|
||||
|
||||
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
|
||||
try {
|
||||
const createdCipher = await this.cipherService.createWithServer(encCipher);
|
||||
const createdCipher = await this.cipherService.createWithServer(cipher, activeUserId);
|
||||
const encryptedCreatedCipher = await this.cipherService.encrypt(createdCipher, activeUserId);
|
||||
|
||||
return createdCipher;
|
||||
return encryptedCreatedCipher.cipher;
|
||||
} catch {
|
||||
throw new Error("Unable to create cipher");
|
||||
}
|
||||
@@ -316,8 +315,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map(async (a) => {
|
||||
if (a) {
|
||||
const encCipher = await this.cipherService.encrypt(cipher, a.id);
|
||||
await this.cipherService.updateWithServer(encCipher);
|
||||
await this.cipherService.updateWithServer(cipher, a.id);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -166,8 +166,7 @@ export class EncryptedMessageHandlerService {
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
const encrypted = await this.cipherService.encrypt(cipherView, activeUserId);
|
||||
await this.cipherService.createWithServer(encrypted);
|
||||
await this.cipherService.createWithServer(cipherView, activeUserId);
|
||||
|
||||
// Notify other clients of new login
|
||||
await this.messagingService.send("addedCipher");
|
||||
@@ -212,9 +211,8 @@ export class EncryptedMessageHandlerService {
|
||||
cipherView.login.password = credentialUpdatePayload.password;
|
||||
cipherView.login.username = credentialUpdatePayload.userName;
|
||||
cipherView.login.uris[0].uri = credentialUpdatePayload.uri;
|
||||
const encrypted = await this.cipherService.encrypt(cipherView, activeUserId);
|
||||
|
||||
await this.cipherService.updateWithServer(encrypted);
|
||||
await this.cipherService.updateWithServer(cipherView, activeUserId);
|
||||
|
||||
// Notify other clients of update
|
||||
await this.messagingService.send("editedCipher");
|
||||
|
||||
@@ -193,6 +193,7 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
formConfig,
|
||||
activeCollectionId,
|
||||
disableForm,
|
||||
isAdminConsoleAction: true,
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(this.vaultItemDialogRef.closed);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ title }}
|
||||
</span>
|
||||
|
||||
@if (isCipherArchived) {
|
||||
@if (isCipherArchived && !params.isAdminConsoleAction) {
|
||||
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,25 @@ describe("VaultItemDialogComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive badge", () => {
|
||||
it('should show "archived" badge when the item is archived and not an admin console action', () => {
|
||||
component.setTestCipher({ isArchived: true });
|
||||
component.setTestParams({ mode: "view" });
|
||||
fixture.detectChanges();
|
||||
const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]"));
|
||||
expect(archivedBadge).toBeTruthy();
|
||||
expect(archivedBadge.nativeElement.textContent.trim()).toBe("archived");
|
||||
});
|
||||
|
||||
it('should not show "archived" badge when the item is archived and is an admin console action', () => {
|
||||
component.setTestCipher({ isArchived: true });
|
||||
component.setTestParams({ mode: "view", isAdminConsoleAction: true });
|
||||
fixture.detectChanges();
|
||||
const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]"));
|
||||
expect(archivedBadge).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitButtonText$", () => {
|
||||
it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => {
|
||||
jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false));
|
||||
|
||||
@@ -12,7 +12,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
CenterPositionStrategy,
|
||||
@@ -148,11 +147,16 @@ export class BulkDeleteDialogComponent {
|
||||
}
|
||||
|
||||
private async deleteCiphersAdmin(ciphers: string[]): Promise<any> {
|
||||
const deleteRequest = new CipherBulkDeleteRequest(ciphers, this.organization.id);
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
if (this.permanent) {
|
||||
return await this.apiService.deleteManyCiphersAdmin(deleteRequest);
|
||||
await this.cipherService.deleteManyWithServer(ciphers, userId, true, this.organization.id);
|
||||
} else {
|
||||
return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest);
|
||||
await this.cipherService.softDeleteManyWithServer(
|
||||
ciphers,
|
||||
userId,
|
||||
true,
|
||||
this.organization.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1536,8 +1536,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipherFullView = await this.cipherService.getFullCipherView(cipher);
|
||||
cipherFullView.favorite = !cipherFullView.favorite;
|
||||
const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId);
|
||||
await this.cipherService.updateWithServer(encryptedCipher);
|
||||
await this.cipherService.updateWithServer(cipherFullView, activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -303,6 +303,7 @@ import {
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
|
||||
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
|
||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
@@ -321,6 +322,7 @@ import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
} from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
@@ -590,6 +592,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultDomainSettingsService,
|
||||
deps: [StateProvider, PolicyServiceAbstraction, AccountService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CipherSdkService,
|
||||
useClass: DefaultCipherSdkService,
|
||||
deps: [SdkService, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CipherServiceAbstraction,
|
||||
useFactory: (
|
||||
@@ -607,6 +614,7 @@ const safeProviders: SafeProvider[] = [
|
||||
logService: LogService,
|
||||
cipherEncryptionService: CipherEncryptionService,
|
||||
messagingService: MessagingServiceAbstraction,
|
||||
cipherSdkService: CipherSdkService,
|
||||
) =>
|
||||
new CipherService(
|
||||
keyService,
|
||||
@@ -623,6 +631,7 @@ const safeProviders: SafeProvider[] = [
|
||||
logService,
|
||||
cipherEncryptionService,
|
||||
messagingService,
|
||||
cipherSdkService,
|
||||
),
|
||||
deps: [
|
||||
KeyService,
|
||||
@@ -639,6 +648,7 @@ const safeProviders: SafeProvider[] = [
|
||||
LogService,
|
||||
CipherEncryptionService,
|
||||
MessagingServiceAbstraction,
|
||||
CipherSdkService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -65,9 +65,9 @@ export enum FeatureFlag {
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -128,8 +128,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,17 +254,17 @@ describe("FidoAuthenticatorService", () => {
|
||||
}
|
||||
|
||||
it("should save credential to vault if request confirmed by user", async () => {
|
||||
const encryptedCipher = Symbol();
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
|
||||
|
||||
await authenticator.makeCredential(params, windowReference);
|
||||
|
||||
const saved = cipherService.encrypt.mock.lastCall?.[0];
|
||||
expect(saved).toEqual(
|
||||
const savedCipher = cipherService.updateWithServer.mock.lastCall?.[0];
|
||||
const actualUserId = cipherService.updateWithServer.mock.lastCall?.[1];
|
||||
expect(actualUserId).toEqual(userId);
|
||||
expect(savedCipher).toEqual(
|
||||
expect.objectContaining({
|
||||
type: CipherType.Login,
|
||||
name: existingCipher.name,
|
||||
@@ -288,7 +288,6 @@ describe("FidoAuthenticatorService", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher);
|
||||
});
|
||||
|
||||
/** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
@@ -361,17 +360,14 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
|
||||
cipherService.decrypt.mockResolvedValue(cipher);
|
||||
cipherService.encrypt.mockImplementation(async (cipher) => {
|
||||
cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
|
||||
return { cipher: {} as any as Cipher, encryptedFor: userId };
|
||||
});
|
||||
cipherService.createWithServer.mockImplementation(async ({ cipher }) => {
|
||||
cipher.id = cipherId;
|
||||
cipherService.createWithServer.mockImplementation(async (cipherView, _userId) => {
|
||||
cipherView.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
cipherService.updateWithServer.mockImplementation(async ({ cipher }) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
cipherService.updateWithServer.mockImplementation(async (cipherView, _userId) => {
|
||||
cipherView.id = cipherId;
|
||||
cipherView.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
|
||||
return cipherView;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -701,14 +697,11 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
/** Spec: Increment the credential associated signature counter */
|
||||
it("should increment counter and save to server when stored counter is larger than zero", async () => {
|
||||
const encrypted = Symbol();
|
||||
cipherService.encrypt.mockResolvedValue(encrypted as any);
|
||||
ciphers[0].login.fido2Credentials[0].counter = 9000;
|
||||
|
||||
await authenticator.getAssertion(params, windowReference);
|
||||
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted);
|
||||
expect(cipherService.encrypt).toHaveBeenCalledWith(
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: ciphers[0].id,
|
||||
login: expect.objectContaining({
|
||||
@@ -725,8 +718,6 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
/** Spec: Authenticators that do not implement a signature counter leave the signCount in the authenticator data constant at zero. */
|
||||
it("should not save to server when stored counter is zero", async () => {
|
||||
const encrypted = Symbol();
|
||||
cipherService.encrypt.mockResolvedValue(encrypted as any);
|
||||
ciphers[0].login.fido2Credentials[0].counter = 0;
|
||||
|
||||
await authenticator.getAssertion(params, windowReference);
|
||||
|
||||
@@ -187,8 +187,7 @@ export class Fido2AuthenticatorService<
|
||||
if (Utils.isNullOrEmpty(cipher.login.username)) {
|
||||
cipher.login.username = fido2Credential.userName;
|
||||
}
|
||||
const reencrypted = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(reencrypted);
|
||||
await this.cipherService.updateWithServer(cipher, activeUserId);
|
||||
await this.cipherService.clearCache(activeUserId);
|
||||
credentialId = fido2Credential.credentialId;
|
||||
} catch (error) {
|
||||
@@ -328,8 +327,7 @@ export class Fido2AuthenticatorService<
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
);
|
||||
const encrypted = await this.cipherService.encrypt(selectedCipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(encrypted);
|
||||
await this.cipherService.updateWithServer(selectedCipher, activeUserId);
|
||||
await this.cipherService.clearCache(activeUserId);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export class DefaultSdkService implements SdkService {
|
||||
client$ = this.environmentService.environment$.pipe(
|
||||
concatMap(async (env) => {
|
||||
await SdkLoadService.Ready;
|
||||
const settings = this.toSettings(env);
|
||||
const settings = await this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService),
|
||||
settings,
|
||||
@@ -210,7 +210,7 @@ export class DefaultSdkService implements SdkService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const settings = this.toSettings(env);
|
||||
const settings = await this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService, userId),
|
||||
settings,
|
||||
@@ -322,11 +322,12 @@ export class DefaultSdkService implements SdkService {
|
||||
client.platform().load_flags(featureFlagMap);
|
||||
}
|
||||
|
||||
private toSettings(env: Environment): ClientSettings {
|
||||
private async toSettings(env: Environment): Promise<ClientSettings> {
|
||||
return {
|
||||
apiUrl: env.getApiUrl(),
|
||||
identityUrl: env.getIdentityUrl(),
|
||||
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
|
||||
bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(),
|
||||
userAgent: this.userAgent ?? navigator.userAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
|
||||
client$ = this.environmentService.environment$.pipe(
|
||||
concatMap(async (env) => {
|
||||
await SdkLoadService.Ready;
|
||||
const settings = this.toSettings(env);
|
||||
const settings = await this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService),
|
||||
settings,
|
||||
@@ -137,7 +137,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const settings = this.toSettings(env);
|
||||
const settings = await this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService, userId),
|
||||
settings,
|
||||
@@ -185,12 +185,13 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
|
||||
client.platform().load_flags(featureFlagMap);
|
||||
}
|
||||
|
||||
private toSettings(env: Environment): ClientSettings {
|
||||
private async toSettings(env: Environment): Promise<ClientSettings> {
|
||||
return {
|
||||
apiUrl: env.getApiUrl(),
|
||||
identityUrl: env.getIdentityUrl(),
|
||||
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
|
||||
userAgent: this.userAgent ?? navigator.userAgent,
|
||||
bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
109
libs/common/src/vault/abstractions/cipher-sdk.service.ts
Normal file
109
libs/common/src/vault/abstractions/cipher-sdk.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
/**
|
||||
* Service responsible for cipher operations using the SDK.
|
||||
*/
|
||||
export abstract class CipherSdkService {
|
||||
/**
|
||||
* Creates a new cipher on the server using the SDK.
|
||||
*
|
||||
* @param cipherView The cipher view to create
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param orgAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves to the created cipher view
|
||||
*/
|
||||
abstract createWithServer(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined>;
|
||||
|
||||
/**
|
||||
* Updates a cipher on the server using the SDK.
|
||||
*
|
||||
* @param cipher The cipher view to update
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param originalCipherView The original cipher view before changes (optional, used for admin operations)
|
||||
* @param orgAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves to the updated cipher view
|
||||
*/
|
||||
abstract updateWithServer(
|
||||
cipher: CipherView,
|
||||
userId: UserId,
|
||||
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>;
|
||||
}
|
||||
@@ -119,9 +119,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
* @returns A promise that resolves to the created cipher
|
||||
*/
|
||||
abstract createWithServer(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher>;
|
||||
): Promise<CipherView>;
|
||||
|
||||
/**
|
||||
* Update a cipher with the server
|
||||
* @param cipher The cipher to update
|
||||
@@ -131,10 +133,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
* @returns A promise that resolves to the updated cipher
|
||||
*/
|
||||
abstract updateWithServer(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
isNotClone?: boolean,
|
||||
): Promise<Cipher>;
|
||||
): Promise<CipherView>;
|
||||
|
||||
/**
|
||||
* Move a cipher to an organization by re-encrypting its keys with the organization's key.
|
||||
@@ -227,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,
|
||||
@@ -244,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>;
|
||||
@@ -272,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.
|
||||
|
||||
@@ -353,4 +353,366 @@ describe("CipherView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Note: These tests use jest.requireActual() because the file has jest.mock() calls
|
||||
// at the top that mock LoginView, FieldView, etc. Those mocks are needed for other tests
|
||||
// but interfere with these tests which need the real implementations.
|
||||
describe("toSdkCreateCipherRequest", () => {
|
||||
it("maps all properties correctly for a login cipher", () => {
|
||||
const { FieldView: RealFieldView } = jest.requireActual("./field.view");
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c";
|
||||
cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f";
|
||||
cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"];
|
||||
cipherView.name = "Test Login";
|
||||
cipherView.notes = "Test notes";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.favorite = true;
|
||||
cipherView.reprompt = CipherRepromptType.Password;
|
||||
|
||||
const field = new RealFieldView();
|
||||
field.name = "testField";
|
||||
field.value = "testValue";
|
||||
field.type = SdkFieldType.Text;
|
||||
cipherView.fields = [field];
|
||||
|
||||
cipherView.login = new RealLoginView();
|
||||
cipherView.login.username = "testuser";
|
||||
cipherView.login.password = "testpass";
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c"));
|
||||
expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f"));
|
||||
expect(result.collectionIds).toEqual([asUuid("b0473506-3c3c-4260-a734-dfaaf833ab6f")]);
|
||||
expect(result.name).toBe("Test Login");
|
||||
expect(result.notes).toBe("Test notes");
|
||||
expect(result.favorite).toBe(true);
|
||||
expect(result.reprompt).toBe(CipherRepromptType.Password);
|
||||
expect(result.fields).toHaveLength(1);
|
||||
expect(result.fields![0]).toMatchObject({
|
||||
name: "testField",
|
||||
value: "testValue",
|
||||
type: SdkFieldType.Text,
|
||||
});
|
||||
expect(result.type).toHaveProperty("login");
|
||||
expect((result.type as any).login).toMatchObject({
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles undefined organizationId and folderId", () => {
|
||||
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.type = CipherType.SecureNote;
|
||||
cipherView.secureNote = new RealSecureNoteView();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.organizationId).toBeUndefined();
|
||||
expect(result.folderId).toBeUndefined();
|
||||
expect(result.name).toBe("Test Cipher");
|
||||
});
|
||||
|
||||
it("handles empty collectionIds array", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.collectionIds = [];
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.collectionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults favorite to false when undefined", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.favorite = undefined as any;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.favorite).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults reprompt to None when undefined", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.reprompt = undefined as any;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.reprompt).toBe(CipherRepromptType.None);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["Login", CipherType.Login, "login.view", "LoginView"],
|
||||
["Card", CipherType.Card, "card.view", "CardView"],
|
||||
["Identity", CipherType.Identity, "identity.view", "IdentityView"],
|
||||
["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"],
|
||||
["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"],
|
||||
])(
|
||||
"creates correct type property for %s cipher",
|
||||
(typeName: string, cipherType: CipherType, moduleName: string, className: string) => {
|
||||
const module = jest.requireActual(`./${moduleName}`);
|
||||
const ViewClass = module[className];
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = `Test ${typeName}`;
|
||||
cipherView.type = cipherType;
|
||||
|
||||
// Set the appropriate view property
|
||||
const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
||||
(cipherView as any)[viewPropertyName] = new ViewClass();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
||||
expect(result.type).toHaveProperty(typeKey);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("toSdkUpdateCipherRequest", () => {
|
||||
it("maps all properties correctly for an update request", () => {
|
||||
const { FieldView: RealFieldView } = jest.requireActual("./field.view");
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c";
|
||||
cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f";
|
||||
cipherView.name = "Updated Login";
|
||||
cipherView.notes = "Updated notes";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.favorite = true;
|
||||
cipherView.reprompt = CipherRepromptType.Password;
|
||||
cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z");
|
||||
cipherView.archivedDate = new Date("2022-01-03T12:00:00.000Z");
|
||||
cipherView.key = new EncString("cipher-key");
|
||||
|
||||
const mockField = new RealFieldView();
|
||||
mockField.name = "testField";
|
||||
mockField.value = "testValue";
|
||||
cipherView.fields = [mockField];
|
||||
|
||||
cipherView.login = new RealLoginView();
|
||||
cipherView.login.username = "testuser";
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
expect(result.id).toEqual(asUuid("0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"));
|
||||
expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c"));
|
||||
expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f"));
|
||||
expect(result.name).toBe("Updated Login");
|
||||
expect(result.notes).toBe("Updated notes");
|
||||
expect(result.favorite).toBe(true);
|
||||
expect(result.reprompt).toBe(CipherRepromptType.Password);
|
||||
expect(result.revisionDate).toBe("2022-01-02T12:00:00.000Z");
|
||||
expect(result.archivedDate).toBe("2022-01-03T12:00:00.000Z");
|
||||
expect(result.fields).toHaveLength(1);
|
||||
expect(result.fields![0]).toMatchObject({
|
||||
name: "testField",
|
||||
value: "testValue",
|
||||
});
|
||||
expect(result.type).toHaveProperty("login");
|
||||
expect((result.type as any).login).toMatchObject({
|
||||
username: "testuser",
|
||||
});
|
||||
expect(result.key).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles undefined optional properties", () => {
|
||||
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.type = CipherType.SecureNote;
|
||||
cipherView.secureNote = new RealSecureNoteView();
|
||||
cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z");
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
expect(result.organizationId).toBeUndefined();
|
||||
expect(result.folderId).toBeUndefined();
|
||||
expect(result.archivedDate).toBeUndefined();
|
||||
expect(result.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("converts dates to ISO strings", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
cipherView.revisionDate = new Date("2022-05-15T10:30:00.000Z");
|
||||
cipherView.archivedDate = new Date("2022-06-20T14:45:00.000Z");
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
expect(result.revisionDate).toBe("2022-05-15T10:30:00.000Z");
|
||||
expect(result.archivedDate).toBe("2022-06-20T14:45:00.000Z");
|
||||
});
|
||||
|
||||
it("includes attachments when present", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
const { AttachmentView: RealAttachmentView } = jest.requireActual("./attachment.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
|
||||
const attachment1 = new RealAttachmentView();
|
||||
attachment1.id = "attachment-id-1";
|
||||
attachment1.fileName = "file1.txt";
|
||||
|
||||
const attachment2 = new RealAttachmentView();
|
||||
attachment2.id = "attachment-id-2";
|
||||
attachment2.fileName = "file2.pdf";
|
||||
|
||||
cipherView.attachments = [attachment1, attachment2];
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
expect(result.attachments).toHaveLength(2);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["Login", CipherType.Login, "login.view", "LoginView"],
|
||||
["Card", CipherType.Card, "card.view", "CardView"],
|
||||
["Identity", CipherType.Identity, "identity.view", "IdentityView"],
|
||||
["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"],
|
||||
["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"],
|
||||
])(
|
||||
"creates correct type property for %s cipher",
|
||||
(typeName: string, cipherType: CipherType, moduleName: string, className: string) => {
|
||||
const module = jest.requireActual(`./${moduleName}`);
|
||||
const ViewClass = module[className];
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.name = `Test ${typeName}`;
|
||||
cipherView.type = cipherType;
|
||||
|
||||
// Set the appropriate view property
|
||||
const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
||||
(cipherView as any)[viewPropertyName] = new ViewClass();
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
||||
expect(result.type).toHaveProperty(typeKey);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("getSdkCipherViewType", () => {
|
||||
it("returns login type for Login cipher", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
cipherView.login.username = "testuser";
|
||||
cipherView.login.password = "testpass";
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("login");
|
||||
expect((result as any).login).toMatchObject({
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns card type for Card cipher", () => {
|
||||
const { CardView: RealCardView } = jest.requireActual("./card.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.Card;
|
||||
cipherView.card = new RealCardView();
|
||||
cipherView.card.cardholderName = "John Doe";
|
||||
cipherView.card.number = "4111111111111111";
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("card");
|
||||
expect((result as any).card.cardholderName).toBe("John Doe");
|
||||
expect((result as any).card.number).toBe("4111111111111111");
|
||||
});
|
||||
|
||||
it("returns identity type for Identity cipher", () => {
|
||||
const { IdentityView: RealIdentityView } = jest.requireActual("./identity.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.Identity;
|
||||
cipherView.identity = new RealIdentityView();
|
||||
cipherView.identity.firstName = "John";
|
||||
cipherView.identity.lastName = "Doe";
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("identity");
|
||||
expect((result as any).identity.firstName).toBe("John");
|
||||
expect((result as any).identity.lastName).toBe("Doe");
|
||||
});
|
||||
|
||||
it("returns secureNote type for SecureNote cipher", () => {
|
||||
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.SecureNote;
|
||||
cipherView.secureNote = new RealSecureNoteView();
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("secureNote");
|
||||
});
|
||||
|
||||
it("returns sshKey type for SshKey cipher", () => {
|
||||
const { SshKeyView: RealSshKeyView } = jest.requireActual("./ssh-key.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.SshKey;
|
||||
cipherView.sshKey = new RealSshKeyView();
|
||||
cipherView.sshKey.privateKey = "privateKeyData";
|
||||
cipherView.sshKey.publicKey = "publicKeyData";
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("sshKey");
|
||||
expect((result as any).sshKey.privateKey).toBe("privateKeyData");
|
||||
expect((result as any).sshKey.publicKey).toBe("publicKeyData");
|
||||
});
|
||||
|
||||
it("defaults to empty login for unknown cipher type", () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = 999 as CipherType;
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("login");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { asUuid, uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { ItemView } from "@bitwarden/common/vault/models/view/item.view";
|
||||
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
|
||||
import {
|
||||
CipherCreateRequest,
|
||||
CipherEditRequest,
|
||||
CipherViewType,
|
||||
CipherView as SdkCipherView,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { View } from "../../../models/view/view";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
@@ -332,6 +337,85 @@ export class CipherView implements View, InitializerMetadata {
|
||||
return cipherView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView to an SDK CipherCreateRequest
|
||||
*
|
||||
* @returns {CipherCreateRequest} The SDK cipher create request object
|
||||
*/
|
||||
toSdkCreateCipherRequest(): CipherCreateRequest {
|
||||
const sdkCipherCreateRequest: CipherCreateRequest = {
|
||||
organizationId: this.organizationId ? asUuid(this.organizationId) : undefined,
|
||||
collectionIds: this.collectionIds ? this.collectionIds.map((i) => asUuid(i)) : [],
|
||||
folderId: this.folderId ? asUuid(this.folderId) : undefined,
|
||||
name: this.name ?? "",
|
||||
notes: this.notes,
|
||||
favorite: this.favorite ?? false,
|
||||
reprompt: this.reprompt ?? CipherRepromptType.None,
|
||||
fields: this.fields?.map((f) => f.toSdkFieldView()),
|
||||
type: this.getSdkCipherViewType(),
|
||||
};
|
||||
|
||||
return sdkCipherCreateRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView to an SDK CipherEditRequest
|
||||
*
|
||||
* @returns {CipherEditRequest} The SDK cipher edit request object
|
||||
*/
|
||||
toSdkUpdateCipherRequest(): CipherEditRequest {
|
||||
const sdkCipherEditRequest: CipherEditRequest = {
|
||||
id: asUuid(this.id),
|
||||
organizationId: this.organizationId ? asUuid(this.organizationId) : undefined,
|
||||
folderId: this.folderId ? asUuid(this.folderId) : undefined,
|
||||
name: this.name ?? "",
|
||||
notes: this.notes,
|
||||
favorite: this.favorite ?? false,
|
||||
reprompt: this.reprompt ?? CipherRepromptType.None,
|
||||
fields: this.fields?.map((f) => f.toSdkFieldView()),
|
||||
type: this.getSdkCipherViewType(),
|
||||
revisionDate: this.revisionDate?.toISOString(),
|
||||
archivedDate: this.archivedDate?.toISOString(),
|
||||
attachments: this.attachments?.map((a) => a.toSdkAttachmentView()),
|
||||
key: this.key?.toSdk(),
|
||||
};
|
||||
|
||||
return sdkCipherEditRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SDK CipherViewType object for the cipher.
|
||||
*
|
||||
* @returns {CipherViewType} The SDK CipherViewType for the cipher.t
|
||||
*/
|
||||
getSdkCipherViewType(): CipherViewType {
|
||||
let viewType: CipherViewType;
|
||||
switch (this.type) {
|
||||
case CipherType.Card:
|
||||
viewType = { card: this.card?.toSdkCardView() };
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
viewType = { identity: this.identity?.toSdkIdentityView() };
|
||||
break;
|
||||
case CipherType.Login:
|
||||
viewType = { login: this.login?.toSdkLoginView() };
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
viewType = { secureNote: this.secureNote?.toSdkSecureNoteView() };
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
viewType = { sshKey: this.sshKey?.toSdkSshKeyView() };
|
||||
break;
|
||||
default:
|
||||
viewType = {
|
||||
// Default to empty login - should not be valid code path.
|
||||
login: new LoginView().toSdkLoginView(),
|
||||
};
|
||||
break;
|
||||
}
|
||||
return viewType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView to SdkCipherView
|
||||
*
|
||||
|
||||
534
libs/common/src/vault/services/cipher-sdk.service.spec.ts
Normal file
534
libs/common/src/vault/services/cipher-sdk.service.spec.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { UserId, CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
|
||||
import { DefaultCipherSdkService } from "./cipher-sdk.service";
|
||||
|
||||
describe("DefaultCipherSdkService", () => {
|
||||
const sdkService = mock<SdkService>();
|
||||
const logService = mock<LogService>();
|
||||
const userId = "test-user-id" as UserId;
|
||||
const cipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId;
|
||||
|
||||
let cipherSdkService: DefaultCipherSdkService;
|
||||
let mockSdkClient: any;
|
||||
let mockCiphersSdk: any;
|
||||
let mockAdminSdk: any;
|
||||
let mockVaultSdk: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the SDK client chain for admin operations
|
||||
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 = {
|
||||
ciphers: jest.fn().mockReturnValue(mockCiphersSdk),
|
||||
};
|
||||
const mockSdkValue = {
|
||||
vault: jest.fn().mockReturnValue(mockVaultSdk),
|
||||
};
|
||||
mockSdkClient = {
|
||||
take: jest.fn().mockReturnValue({
|
||||
value: mockSdkValue,
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
}),
|
||||
};
|
||||
|
||||
// Mock sdkService to return the mock client
|
||||
sdkService.userClient$.mockReturnValue(of(mockSdkClient));
|
||||
|
||||
cipherSdkService = new DefaultCipherSdkService(sdkService, logService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("createWithServer()", () => {
|
||||
it("should create cipher using SDK when orgAdmin is false", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockCiphersSdk.create.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.createWithServer(cipherView, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: cipherView.name,
|
||||
organizationId: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result?.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should create cipher using SDK admin API when orgAdmin is true", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Test Admin Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockAdminSdk.create.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.createWithServer(cipherView, userId, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: cipherView.name,
|
||||
}),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result?.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
|
||||
await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to create cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
|
||||
mockCiphersSdk.create.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to create cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWithServer()", () => {
|
||||
it("should update cipher using SDK when orgAdmin is false", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Updated Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockCiphersSdk.edit.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.edit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
name: cipherView.name,
|
||||
}),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should update cipher using SDK admin API when orgAdmin is true", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Updated Admin Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const originalCipherView = new CipherView();
|
||||
originalCipherView.id = cipherId;
|
||||
originalCipherView.name = "Original Cipher";
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.updateWithServer(
|
||||
cipherView,
|
||||
userId,
|
||||
originalCipherView,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.edit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
name: cipherView.name,
|
||||
}),
|
||||
originalCipherView.toSdkCipherView(),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should update cipher using SDK admin API without originalCipherView", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Updated Admin Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.edit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
name: cipherView.name,
|
||||
}),
|
||||
expect.anything(), // Empty CipherView - timestamps vary so we just verify it was called
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
|
||||
await expect(
|
||||
cipherSdkService.updateWithServer(cipherView, userId, undefined, false),
|
||||
).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to update cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
|
||||
mockCiphersSdk.edit.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(
|
||||
cipherSdkService.updateWithServer(cipherView, userId, undefined, false),
|
||||
).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to update cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
263
libs/common/src/vault/services/cipher-sdk.service.ts
Normal file
263
libs/common/src/vault/services/cipher-sdk.service.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { firstValueFrom, switchMap, catchError } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
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";
|
||||
|
||||
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
|
||||
|
||||
export class DefaultCipherSdkService implements CipherSdkService {
|
||||
constructor(
|
||||
private sdkService: SdkService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async createWithServer(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
const sdkCreateRequest = cipherView.toSdkCreateCipherRequest();
|
||||
let result: SdkCipherView;
|
||||
if (orgAdmin) {
|
||||
result = await ref.value.vault().ciphers().admin().create(sdkCreateRequest);
|
||||
} else {
|
||||
result = await ref.value.vault().ciphers().create(sdkCreateRequest);
|
||||
}
|
||||
return CipherView.fromSdkCipherView(result);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to create cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWithServer(
|
||||
cipher: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
const sdkUpdateRequest = cipher.toSdkUpdateCipherRequest();
|
||||
let result: SdkCipherView;
|
||||
if (orgAdmin) {
|
||||
result = await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.admin()
|
||||
.edit(
|
||||
sdkUpdateRequest,
|
||||
originalCipherView?.toSdkCipherView() || new CipherView().toSdkCipherView(),
|
||||
);
|
||||
} else {
|
||||
result = await ref.value.vault().ciphers().edit(sdkUpdateRequest);
|
||||
}
|
||||
return CipherView.fromSdkCipherView(result);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to update cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { ContainerService } from "../../platform/services/container.service";
|
||||
import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid";
|
||||
import { CipherKey, OrgKey, UserKey } from "../../types/key";
|
||||
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
|
||||
import { EncryptionContext } from "../abstractions/cipher.service";
|
||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||
import { SearchService } from "../abstractions/search.service";
|
||||
@@ -54,9 +55,9 @@ function encryptText(clearText: string | Uint8Array) {
|
||||
const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
|
||||
|
||||
const cipherData: CipherData = {
|
||||
id: "id",
|
||||
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId,
|
||||
folderId: "folderId",
|
||||
id: "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId,
|
||||
folderId: "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23",
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
organizationUseTotp: true,
|
||||
@@ -109,12 +110,15 @@ describe("Cipher Service", () => {
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
const cipherEncryptionService = mock<CipherEncryptionService>();
|
||||
const messageSender = mock<MessageSender>();
|
||||
const cipherSdkService = mock<CipherSdkService>();
|
||||
|
||||
const userId = "TestUserId" as UserId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId;
|
||||
|
||||
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));
|
||||
@@ -130,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,
|
||||
@@ -145,6 +153,7 @@ describe("Cipher Service", () => {
|
||||
logService,
|
||||
cipherEncryptionService,
|
||||
messageSender,
|
||||
cipherSdkService,
|
||||
);
|
||||
|
||||
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
|
||||
@@ -207,11 +216,22 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
describe("createWithServer()", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext);
|
||||
jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => {
|
||||
return new CipherView(cipher);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipherAdmin")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext, true);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.createWithServer(cipherView, userId, true);
|
||||
const expectedObj = new CipherCreateRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -219,11 +239,15 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.organizationId = null!;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext, true);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.createWithServer(cipherView, userId, true);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -231,11 +255,15 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.collectionIds = ["123"];
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipherCreate")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.createWithServer(cipherView, userId);
|
||||
const expectedObj = new CipherCreateRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -243,35 +271,84 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.createWithServer(cipherView, userId);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(expectedObj);
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
const expectedResult = new CipherView(encryptionContext.cipher);
|
||||
|
||||
const cipherSdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "createWithServer")
|
||||
.mockResolvedValue(expectedResult);
|
||||
|
||||
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
|
||||
const apiSpy = jest.spyOn(apiService, "postCipher");
|
||||
|
||||
const result = await cipherService.createWithServer(cipherView, userId);
|
||||
|
||||
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined);
|
||||
expect(apiSpy).not.toHaveBeenCalled();
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWithServer()", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext);
|
||||
jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => {
|
||||
return new CipherView(cipher);
|
||||
});
|
||||
jest.spyOn(cipherService, "upsert").mockResolvedValue({
|
||||
[cipherData.id as CipherId]: cipherData,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
|
||||
configService.getFeatureFlag$
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockReturnValue(of(false));
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
testCipher.organizationId = orgId;
|
||||
const testContext = { cipher: testCipher, encryptedFor: userId };
|
||||
jest.spyOn(cipherService, "encrypt").mockResolvedValue(testContext);
|
||||
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putCipherAdmin")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext, true);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
.mockImplementation(() => Promise.resolve<any>(testCipher.toCipherData()));
|
||||
const cipherView = new CipherView(testCipher);
|
||||
await cipherService.updateWithServer(cipherView, userId, undefined, true);
|
||||
const expectedObj = new CipherRequest(testContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
||||
expect(spy).toHaveBeenCalledWith(testCipher.id, expectedObj);
|
||||
});
|
||||
|
||||
it("should call apiService.putCipher if cipher.edit is true", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.edit = true;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.updateWithServer(cipherView, userId);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -279,16 +356,75 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.edit = false;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putPartialCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.updateWithServer(cipherView, userId);
|
||||
const expectedObj = new CipherPartialRequest(encryptionContext.cipher);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const cipherView = new CipherView(testCipher);
|
||||
const expectedResult = new CipherView(testCipher);
|
||||
|
||||
const cipherSdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "updateWithServer")
|
||||
.mockResolvedValue(expectedResult);
|
||||
|
||||
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
|
||||
const apiSpy = jest.spyOn(apiService, "putCipher");
|
||||
|
||||
const result = await cipherService.updateWithServer(cipherView, userId);
|
||||
|
||||
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined, undefined);
|
||||
expect(apiSpy).not.toHaveBeenCalled();
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => {
|
||||
sdkCrudFeatureFlag$.next(true);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const cipherView = new CipherView(testCipher);
|
||||
const originalCipherView = new CipherView(testCipher);
|
||||
const expectedResult = new CipherView(testCipher);
|
||||
|
||||
const cipherSdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "updateWithServer")
|
||||
.mockResolvedValue(expectedResult);
|
||||
|
||||
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
|
||||
const apiSpy = jest.spyOn(apiService, "putCipherAdmin");
|
||||
|
||||
const result = await cipherService.updateWithServer(
|
||||
cipherView,
|
||||
userId,
|
||||
originalCipherView,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(
|
||||
cipherView,
|
||||
userId,
|
||||
originalCipherView,
|
||||
true,
|
||||
);
|
||||
expect(apiSpy).not.toHaveBeenCalled();
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt", () => {
|
||||
@@ -873,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.
|
||||
|
||||
@@ -42,6 +42,7 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid
|
||||
import { OrgKey, UserKey } from "../../types/key";
|
||||
import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities";
|
||||
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
|
||||
import {
|
||||
CipherService as CipherServiceAbstraction,
|
||||
EncryptionContext,
|
||||
@@ -105,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,
|
||||
@@ -120,6 +128,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
private logService: LogService,
|
||||
private cipherEncryptionService: CipherEncryptionService,
|
||||
private messageSender: MessageSender,
|
||||
private cipherSdkService: CipherSdkService,
|
||||
) {}
|
||||
|
||||
localData$(userId: UserId): Observable<Record<CipherId, LocalData>> {
|
||||
@@ -903,6 +912,38 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
async createWithServer(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
|
||||
if (useSdk) {
|
||||
return (
|
||||
(await this.createWithServerUsingSdk(cipherView, userId, orgAdmin)) || new CipherView()
|
||||
);
|
||||
}
|
||||
|
||||
const encrypted = await this.encrypt(cipherView, userId);
|
||||
const result = await this.createWithServer_legacy(encrypted, orgAdmin);
|
||||
return await this.decrypt(result, userId);
|
||||
}
|
||||
|
||||
private async createWithServerUsingSdk(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | void> {
|
||||
const resultCipherView = await this.cipherSdkService.createWithServer(
|
||||
cipherView,
|
||||
userId,
|
||||
orgAdmin,
|
||||
);
|
||||
await this.clearCache(userId);
|
||||
return resultCipherView;
|
||||
}
|
||||
|
||||
private async createWithServer_legacy(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher> {
|
||||
@@ -929,6 +970,40 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
async updateWithServer(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
|
||||
if (useSdk) {
|
||||
return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin);
|
||||
}
|
||||
|
||||
const encrypted = await this.encrypt(cipherView, userId);
|
||||
const updatedCipher = await this.updateWithServer_legacy(encrypted, orgAdmin);
|
||||
const updatedCipherView = await this.decrypt(updatedCipher, userId);
|
||||
return updatedCipherView;
|
||||
}
|
||||
|
||||
async updateWithServerUsingSdk(
|
||||
cipher: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const resultCipherView = await this.cipherSdkService.updateWithServer(
|
||||
cipher,
|
||||
userId,
|
||||
originalCipherView,
|
||||
orgAdmin,
|
||||
);
|
||||
await this.clearCache(userId);
|
||||
return resultCipherView;
|
||||
}
|
||||
|
||||
async updateWithServer_legacy(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher> {
|
||||
@@ -1119,8 +1194,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
//in order to keep item and it's attachments with the same encryption level
|
||||
if (cipher.key != null && !cipherKeyEncryptionEnabled) {
|
||||
const model = await this.decrypt(cipher, userId);
|
||||
const reEncrypted = await this.encrypt(model, userId);
|
||||
await this.updateWithServer(reEncrypted);
|
||||
await this.updateWithServer(model, userId);
|
||||
}
|
||||
|
||||
const encFileName = await this.encryptService.encryptString(filename, cipherEncKey);
|
||||
@@ -1318,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 {
|
||||
@@ -1328,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);
|
||||
@@ -1468,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;
|
||||
@@ -1496,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 {
|
||||
@@ -1506,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);
|
||||
@@ -1550,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);
|
||||
@@ -1566,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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -37,14 +37,13 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
|
||||
// Creating a new cipher
|
||||
if (cipher.id == null || cipher.id === "") {
|
||||
const encrypted = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
savedCipher = await this.cipherService.createWithServer(encrypted, config.admin);
|
||||
return await this.cipherService.decrypt(savedCipher, activeUserId);
|
||||
return await this.cipherService.createWithServer(cipher, activeUserId, config.admin);
|
||||
}
|
||||
|
||||
if (config.originalCipher == null) {
|
||||
throw new Error("Original cipher is required for updating an existing cipher");
|
||||
}
|
||||
const originalCipherView = await this.decryptCipher(config.originalCipher);
|
||||
|
||||
// Updating an existing cipher
|
||||
|
||||
@@ -66,35 +65,31 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
);
|
||||
// If the collectionIds are the same, update the cipher normally
|
||||
} else if (isSetEqual(originalCollectionIds, newCollectionIds)) {
|
||||
const encrypted = await this.cipherService.encrypt(
|
||||
const savedCipherView = await this.cipherService.updateWithServer(
|
||||
cipher,
|
||||
activeUserId,
|
||||
null,
|
||||
null,
|
||||
config.originalCipher,
|
||||
originalCipherView,
|
||||
config.admin,
|
||||
);
|
||||
savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin);
|
||||
savedCipher = await this.cipherService
|
||||
.encrypt(savedCipherView, activeUserId)
|
||||
.then((res) => res.cipher);
|
||||
} else {
|
||||
const encrypted = await this.cipherService.encrypt(
|
||||
cipher,
|
||||
activeUserId,
|
||||
null,
|
||||
null,
|
||||
config.originalCipher,
|
||||
);
|
||||
const encryptedCipher = encrypted.cipher;
|
||||
|
||||
// Updating a cipher with collection changes is not supported with a single request currently
|
||||
// First update the cipher with the original collectionIds
|
||||
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
|
||||
await this.cipherService.updateWithServer(
|
||||
encrypted,
|
||||
cipher.collectionIds = config.originalCipher.collectionIds;
|
||||
const newCipher = await this.cipherService.updateWithServer(
|
||||
cipher,
|
||||
activeUserId,
|
||||
originalCipherView,
|
||||
config.admin || originalCollectionIds.size === 0,
|
||||
);
|
||||
|
||||
// Then save the new collection changes separately
|
||||
encryptedCipher.collectionIds = cipher.collectionIds;
|
||||
newCipher.collectionIds = cipher.collectionIds;
|
||||
|
||||
// TODO: Remove after migrating all SDK ops
|
||||
const { cipher: encryptedCipher } = await this.cipherService.encrypt(newCipher, activeUserId);
|
||||
if (config.admin || originalCollectionIds.size === 0) {
|
||||
// When using an admin config or the cipher was unassigned, update collections as an admin
|
||||
savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher);
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
buttonType="main"
|
||||
size="small"
|
||||
type="button"
|
||||
[label]="'downloadAttachmentName' | i18n: attachment().fileName"
|
||||
[label]="'downloadAttachmentLabel' | i18n"
|
||||
></button>
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => {
|
||||
it("renders delete button", () => {
|
||||
const deleteButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName");
|
||||
expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentLabel");
|
||||
});
|
||||
|
||||
describe("download attachment", () => {
|
||||
|
||||
118
package-lock.json
generated
118
package-lock.json
generated
@@ -14,15 +14,15 @@
|
||||
"libs/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@angular/animations": "20.3.15",
|
||||
"@angular/animations": "20.3.16",
|
||||
"@angular/cdk": "20.2.14",
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/forms": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15",
|
||||
"@angular/platform-browser-dynamic": "20.3.15",
|
||||
"@angular/router": "20.3.15",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/forms": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"@angular/platform-browser-dynamic": "20.3.16",
|
||||
"@angular/router": "20.3.16",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.470",
|
||||
"@electron/fuses": "1.8.0",
|
||||
@@ -74,7 +74,7 @@
|
||||
"@angular-devkit/build-angular": "20.3.12",
|
||||
"@angular-eslint/schematics": "20.7.0",
|
||||
"@angular/cli": "20.3.12",
|
||||
"@angular/compiler-cli": "20.3.15",
|
||||
"@angular/compiler-cli": "20.3.16",
|
||||
"@babel/core": "7.28.5",
|
||||
"@babel/preset-env": "7.28.5",
|
||||
"@compodoc/compodoc": "1.1.32",
|
||||
@@ -2203,9 +2203,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/animations": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.15.tgz",
|
||||
"integrity": "sha512-ikyKfhkxoqQA6JcBN0B9RaN6369sM1XYX81Id0lI58dmWCe7gYfrTp8ejqxxKftl514psQO3pkW8Gn1nJ131Gw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.16.tgz",
|
||||
"integrity": "sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2214,7 +2214,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "20.3.15"
|
||||
"@angular/core": "20.3.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/build": {
|
||||
@@ -2627,9 +2627,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz",
|
||||
"integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz",
|
||||
"integrity": "sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2638,14 +2638,14 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/core": "20.3.16",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz",
|
||||
"integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz",
|
||||
"integrity": "sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2655,9 +2655,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.15.tgz",
|
||||
"integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz",
|
||||
"integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2678,7 +2678,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"typescript": ">=5.8 <6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -2864,9 +2864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz",
|
||||
"integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz",
|
||||
"integrity": "sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2875,7 +2875,7 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
@@ -2889,9 +2889,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.15.tgz",
|
||||
"integrity": "sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz",
|
||||
"integrity": "sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2900,16 +2900,16 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz",
|
||||
"integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz",
|
||||
"integrity": "sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2918,9 +2918,9 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "20.3.15",
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/core": "20.3.15"
|
||||
"@angular/animations": "20.3.16",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/core": "20.3.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
@@ -2929,9 +2929,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser-dynamic": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.15.tgz",
|
||||
"integrity": "sha512-RizuRdBt0d6ongQ2y8cr8YsXFyjF8f91vFfpSNw+cFj+oiEmRC1txcWUlH5bPLD9qSDied8qazUi0Tb8VPQDGw==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz",
|
||||
"integrity": "sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2940,16 +2940,16 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15"
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "20.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.15.tgz",
|
||||
"integrity": "sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ==",
|
||||
"version": "20.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz",
|
||||
"integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -2958,9 +2958,9 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
@@ -32414,9 +32414,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.5",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
|
||||
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
|
||||
"version": "1.11.8",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz",
|
||||
"integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -34690,9 +34690,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ordered-binary": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz",
|
||||
"integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz",
|
||||
"integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
|
||||
18
package.json
18
package.json
@@ -41,7 +41,7 @@
|
||||
"@angular-devkit/build-angular": "20.3.12",
|
||||
"@angular-eslint/schematics": "20.7.0",
|
||||
"@angular/cli": "20.3.12",
|
||||
"@angular/compiler-cli": "20.3.15",
|
||||
"@angular/compiler-cli": "20.3.16",
|
||||
"@babel/core": "7.28.5",
|
||||
"@babel/preset-env": "7.28.5",
|
||||
"@compodoc/compodoc": "1.1.32",
|
||||
@@ -153,15 +153,15 @@
|
||||
"webpack-node-externals": "3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "20.3.15",
|
||||
"@angular/animations": "20.3.16",
|
||||
"@angular/cdk": "20.2.14",
|
||||
"@angular/common": "20.3.15",
|
||||
"@angular/compiler": "20.3.15",
|
||||
"@angular/core": "20.3.15",
|
||||
"@angular/forms": "20.3.15",
|
||||
"@angular/platform-browser": "20.3.15",
|
||||
"@angular/platform-browser-dynamic": "20.3.15",
|
||||
"@angular/router": "20.3.15",
|
||||
"@angular/common": "20.3.16",
|
||||
"@angular/compiler": "20.3.16",
|
||||
"@angular/core": "20.3.16",
|
||||
"@angular/forms": "20.3.16",
|
||||
"@angular/platform-browser": "20.3.16",
|
||||
"@angular/platform-browser-dynamic": "20.3.16",
|
||||
"@angular/router": "20.3.16",
|
||||
"@bitwarden/sdk-internal": "0.2.0-main.470",
|
||||
"@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
|
||||
"@electron/fuses": "1.8.0",
|
||||
|
||||
Reference in New Issue
Block a user