mirror of
https://github.com/bitwarden/browser
synced 2026-01-31 08:43:54 +00:00
Merge branch 'main' into PM-29919-Add-dropdown-to-select-email-verification-and-emails-field-to-Send-when-creating-or-editing-a-Send
This commit is contained in:
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,27 +9,3 @@
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||
|
||||
## ⏰ Reminders before review
|
||||
|
||||
- Contributor guidelines followed
|
||||
- All formatters and local linters executed and passed
|
||||
- Written new unit and / or integration tests where applicable
|
||||
- Protected functional changes with optionality (feature flags)
|
||||
- Used internationalization (i18n) for all UI strings
|
||||
- CI builds passed
|
||||
- Communicated to DevOps any deployment requirements
|
||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
||||
|
||||
## 🦮 Reviewer guidelines
|
||||
|
||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
||||
|
||||
- 👍 (`:+1:`) or similar for great changes
|
||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
||||
- ❓ (`:question:`) for questions
|
||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
||||
- 🎨 (`:art:`) for suggestions / improvements
|
||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
||||
|
||||
14
.github/workflows/build-desktop.yml
vendored
14
.github/workflows/build-desktop.yml
vendored
@@ -1894,7 +1894,7 @@ jobs:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -1937,7 +1937,7 @@ jobs:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -1978,7 +1978,7 @@ jobs:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -2033,7 +2033,7 @@ jobs:
|
||||
- linux-arm64
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -2086,7 +2086,7 @@ jobs:
|
||||
_CPU_ARCH: ${{ matrix.os == 'ubuntu-22.04' && 'amd64' || 'arm64' }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -2130,7 +2130,7 @@ jobs:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -2174,7 +2174,7 @@ jobs:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Subject, switchMap, timer } from "rxjs";
|
||||
|
||||
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
|
||||
@@ -25,7 +23,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set();
|
||||
private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map();
|
||||
private clearLoginCipherFormDataSubject: Subject<void> = new Subject();
|
||||
private notificationFallbackTimeout: number | NodeJS.Timeout | null;
|
||||
private notificationFallbackTimeout: number | NodeJS.Timeout | null = null;
|
||||
private readonly formSubmissionRequestMethods: Set<string> = new Set(["POST", "PUT", "PATCH"]);
|
||||
private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = {
|
||||
generatedPasswordFilled: ({ message, sender }) =>
|
||||
@@ -63,7 +61,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
if (await this.shouldInitAddLoginOrChangePasswordNotification(message, sender)) {
|
||||
this.websiteOriginsWithFields.set(sender.tab.id, this.getSenderUrlMatchPatterns(sender));
|
||||
const tabId = sender.tab?.id;
|
||||
if (tabId === undefined) {
|
||||
return;
|
||||
}
|
||||
this.websiteOriginsWithFields.set(tabId, this.getSenderUrlMatchPatterns(sender));
|
||||
this.setupWebRequestsListeners();
|
||||
}
|
||||
}
|
||||
@@ -80,11 +82,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
message: OverlayNotificationsExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
const tabId = sender.tab?.id;
|
||||
if (tabId === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(await this.isAddLoginOrChangePasswordNotificationEnabled()) &&
|
||||
!(await this.isSenderFromExcludedDomain(sender)) &&
|
||||
message.details?.fields?.length > 0 &&
|
||||
!this.websiteOriginsWithFields.has(sender.tab.id)
|
||||
(message.details?.fields?.length ?? 0) > 0 &&
|
||||
!this.websiteOriginsWithFields.has(tabId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,8 +114,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
*/
|
||||
private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) {
|
||||
return new Set([
|
||||
...generateDomainMatchPatterns(sender.url),
|
||||
...generateDomainMatchPatterns(sender.tab.url),
|
||||
...(sender.url ? generateDomainMatchPatterns(sender.url) : []),
|
||||
...(sender.tab?.url ? generateDomainMatchPatterns(sender.tab.url) : []),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,7 +130,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
message: OverlayNotificationsExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) => {
|
||||
if (!this.websiteOriginsWithFields.has(sender.tab.id)) {
|
||||
const tabId = sender.tab?.id;
|
||||
if (tabId === undefined || !this.websiteOriginsWithFields.has(tabId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,25 +143,24 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
this.clearLoginCipherFormDataSubject.next();
|
||||
const formData = { uri, username, password, newPassword };
|
||||
|
||||
const existingModifyLoginData = this.modifyLoginCipherFormData.get(sender.tab.id);
|
||||
const existingModifyLoginData = this.modifyLoginCipherFormData.get(tabId);
|
||||
if (existingModifyLoginData) {
|
||||
formData.username = formData.username || existingModifyLoginData.username;
|
||||
formData.password = formData.password || existingModifyLoginData.password;
|
||||
formData.newPassword = formData.newPassword || existingModifyLoginData.newPassword;
|
||||
}
|
||||
|
||||
this.modifyLoginCipherFormData.set(sender.tab.id, formData);
|
||||
this.modifyLoginCipherFormData.set(tabId, formData);
|
||||
|
||||
this.clearNotificationFallbackTimeout();
|
||||
this.notificationFallbackTimeout = setTimeout(
|
||||
() =>
|
||||
this.setupNotificationInitTrigger(
|
||||
sender.tab.id,
|
||||
"",
|
||||
this.modifyLoginCipherFormData.get(sender.tab.id),
|
||||
).catch((error) => this.logService.error(error)),
|
||||
1500,
|
||||
);
|
||||
this.notificationFallbackTimeout = setTimeout(() => {
|
||||
const modifyLoginData = this.modifyLoginCipherFormData.get(tabId);
|
||||
if (modifyLoginData) {
|
||||
this.setupNotificationInitTrigger(tabId, "", modifyLoginData).catch((error) =>
|
||||
this.logService.error(error),
|
||||
);
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -176,6 +183,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
private async isSenderFromExcludedDomain(sender: chrome.runtime.MessageSender): Promise<boolean> {
|
||||
try {
|
||||
const senderOrigin = sender.origin;
|
||||
if (!senderOrigin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverConfig = await this.notificationBackground.getActiveUserServerConfig();
|
||||
const activeUserVault = serverConfig?.environment?.vault;
|
||||
if (activeUserVault === senderOrigin) {
|
||||
@@ -232,11 +243,12 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
details: chrome.webRequest.OnBeforeRequestDetails,
|
||||
): undefined => {
|
||||
if (this.isPostSubmissionFormRedirection(details)) {
|
||||
this.setupNotificationInitTrigger(
|
||||
details.tabId,
|
||||
details.requestId,
|
||||
this.modifyLoginCipherFormData.get(details.tabId),
|
||||
).catch((error) => this.logService.error(error));
|
||||
const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId);
|
||||
if (modifyLoginData) {
|
||||
this.setupNotificationInitTrigger(details.tabId, details.requestId, modifyLoginData).catch(
|
||||
(error) => this.logService.error(error),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -385,6 +397,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
this.clearNotificationFallbackTimeout();
|
||||
|
||||
const tab = await BrowserApi.getTab(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab.status !== "complete") {
|
||||
await this.delayNotificationInitUntilTabIsComplete(tabId, requestId, modifyLoginData);
|
||||
return;
|
||||
@@ -410,7 +426,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
const handleWebNavigationOnCompleted = async () => {
|
||||
chrome.webNavigation.onCompleted.removeListener(handleWebNavigationOnCompleted);
|
||||
const tab = await BrowserApi.getTab(tabId);
|
||||
await this.processNotifications(requestId, modifyLoginData, tab);
|
||||
if (tab) {
|
||||
await this.processNotifications(requestId, modifyLoginData, tab);
|
||||
}
|
||||
};
|
||||
chrome.webNavigation.onCompleted.addListener(handleWebNavigationOnCompleted);
|
||||
};
|
||||
|
||||
@@ -4,8 +4,10 @@ import { of } from "rxjs";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import {
|
||||
AUTOFILL_ID,
|
||||
COPY_IDENTIFIER_ID,
|
||||
COPY_PASSWORD_ID,
|
||||
COPY_USERNAME_ID,
|
||||
COPY_VERIFICATION_CODE_ID,
|
||||
@@ -85,6 +87,7 @@ describe("ContextMenuClickedHandler", () => {
|
||||
accountService = mockAccountServiceWith(mockUserId as UserId);
|
||||
totpService = mock();
|
||||
eventCollectionService = mock();
|
||||
userVerificationService = mock();
|
||||
|
||||
sut = new ContextMenuClickedHandler(
|
||||
copyToClipboard,
|
||||
@@ -102,6 +105,93 @@ describe("ContextMenuClickedHandler", () => {
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
describe("run", () => {
|
||||
beforeEach(() => {
|
||||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||
userVerificationService.hasMasterPasswordAndMasterKeyHash.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
const runWithUrl = (data: chrome.contextMenus.OnClickData) =>
|
||||
sut.run(data, { url: "https://test.com" } as any);
|
||||
|
||||
describe("early returns", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "tab id is missing",
|
||||
data: createData(COPY_IDENTIFIER_ID),
|
||||
tab: { url: "https://test.com" } as any,
|
||||
expectNotCalled: () => expect(copyToClipboard).not.toHaveBeenCalled(),
|
||||
},
|
||||
{
|
||||
name: "tab url is missing",
|
||||
data: createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID),
|
||||
tab: {} as any,
|
||||
expectNotCalled: () => {
|
||||
expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled();
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])("returns early when $name", async ({ data, tab, expectNotCalled }) => {
|
||||
await expect(sut.run(data, tab)).resolves.toBeUndefined();
|
||||
expectNotCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing cipher", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "AUTOFILL",
|
||||
parentId: AUTOFILL_ID,
|
||||
extra: () => expect(autofill).not.toHaveBeenCalled(),
|
||||
},
|
||||
{ label: "username", parentId: COPY_USERNAME_ID, extra: () => {} },
|
||||
{ label: "password", parentId: COPY_PASSWORD_ID, extra: () => {} },
|
||||
{
|
||||
label: "totp",
|
||||
parentId: COPY_VERIFICATION_CODE_ID,
|
||||
extra: () => expect(totpService.getCode$).not.toHaveBeenCalled(),
|
||||
},
|
||||
])("breaks silently when cipher is missing for $label", async ({ parentId, extra }) => {
|
||||
cipherService.getAllDecrypted.mockResolvedValue([]);
|
||||
|
||||
await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined();
|
||||
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
extra();
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing login properties", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "username",
|
||||
parentId: COPY_USERNAME_ID,
|
||||
unset: (c: CipherView): void => (c.login.username = undefined),
|
||||
},
|
||||
{
|
||||
label: "password",
|
||||
parentId: COPY_PASSWORD_ID,
|
||||
unset: (c: CipherView): void => (c.login.password = undefined),
|
||||
},
|
||||
{
|
||||
label: "totp",
|
||||
parentId: COPY_VERIFICATION_CODE_ID,
|
||||
unset: (c: CipherView): void => (c.login.totp = undefined),
|
||||
isTotp: true,
|
||||
},
|
||||
])("breaks silently when $label property is missing", async ({ parentId, unset, isTotp }) => {
|
||||
const cipher = createCipher();
|
||||
unset(cipher);
|
||||
cipherService.getAllDecrypted.mockResolvedValue([cipher]);
|
||||
|
||||
await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined();
|
||||
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
if (isTotp) {
|
||||
expect(totpService.getCode$).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("can generate password", async () => {
|
||||
await sut.run(createData(GENERATE_PASSWORD_ID), { id: 5 } as any);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
@@ -72,6 +70,10 @@ export class ContextMenuClickedHandler {
|
||||
await this.generatePasswordToClipboard(tab);
|
||||
break;
|
||||
case COPY_IDENTIFIER_ID:
|
||||
if (!tab.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab });
|
||||
break;
|
||||
default:
|
||||
@@ -120,6 +122,10 @@ export class ContextMenuClickedHandler {
|
||||
if (isCreateCipherAction) {
|
||||
// pass; defer to logic below
|
||||
} else if (menuItemId === NOOP_COMMAND_SUFFIX) {
|
||||
if (!tab.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const additionalCiphersToGet =
|
||||
info.parentMenuItemId === AUTOFILL_IDENTITY_ID
|
||||
? [CipherType.Identity]
|
||||
@@ -158,6 +164,10 @@ export class ContextMenuClickedHandler {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!cipher) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (await this.isPasswordRepromptRequired(cipher)) {
|
||||
await openVaultItemPasswordRepromptPopout(tab, {
|
||||
cipherId: cipher.id,
|
||||
@@ -176,6 +186,10 @@ export class ContextMenuClickedHandler {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!cipher || !cipher.login?.username) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.copyToClipboard({ text: cipher.login.username, tab: tab });
|
||||
break;
|
||||
case COPY_PASSWORD_ID:
|
||||
@@ -184,6 +198,10 @@ export class ContextMenuClickedHandler {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!cipher || !cipher.login?.password) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (await this.isPasswordRepromptRequired(cipher)) {
|
||||
await openVaultItemPasswordRepromptPopout(tab, {
|
||||
cipherId: cipher.id,
|
||||
@@ -205,6 +223,10 @@ export class ContextMenuClickedHandler {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!cipher || !cipher.login?.totp) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (await this.isPasswordRepromptRequired(cipher)) {
|
||||
await openVaultItemPasswordRepromptPopout(tab, {
|
||||
cipherId: cipher.id,
|
||||
@@ -240,9 +262,10 @@ export class ContextMenuClickedHandler {
|
||||
}
|
||||
|
||||
private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) {
|
||||
const tabId = tab.id!;
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
BrowserApi.sendTabsMessage(
|
||||
tab.id,
|
||||
tabId,
|
||||
{ command: "getClickedElement" },
|
||||
{ frameId: info.frameId },
|
||||
(identifier: string) => {
|
||||
|
||||
@@ -1510,6 +1510,7 @@ export default class MainBackground {
|
||||
this.accountService,
|
||||
this.billingAccountProfileStateService,
|
||||
this.configService,
|
||||
this.logService,
|
||||
this.organizationService,
|
||||
this.platformUtilsService,
|
||||
this.stateProvider,
|
||||
|
||||
@@ -9,7 +9,66 @@ import {
|
||||
import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { PhishingDataService, PhishingData, PHISHING_DOMAINS_KEY } from "./phishing-data.service";
|
||||
import {
|
||||
PhishingDataService,
|
||||
PHISHING_DOMAINS_META_KEY,
|
||||
PHISHING_DOMAINS_BLOB_KEY,
|
||||
PhishingDataMeta,
|
||||
PhishingDataBlob,
|
||||
} from "./phishing-data.service";
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise((resolve) => jest.requireActual("timers").setImmediate(resolve));
|
||||
|
||||
// [FIXME] Move mocking and compression helpers to a shared test utils library
|
||||
// to separate from phishing data service tests.
|
||||
export const setupPhishingMocks = (mockedResult: string | ArrayBuffer = "mocked-data") => {
|
||||
// Store original globals
|
||||
const originals = {
|
||||
Response: global.Response,
|
||||
CompressionStream: global.CompressionStream,
|
||||
DecompressionStream: global.DecompressionStream,
|
||||
Blob: global.Blob,
|
||||
atob: global.atob,
|
||||
btoa: global.btoa,
|
||||
};
|
||||
|
||||
// Mock missing or browser-only globals
|
||||
global.atob = (str) => Buffer.from(str, "base64").toString("binary");
|
||||
global.btoa = (str) => Buffer.from(str, "binary").toString("base64");
|
||||
|
||||
(global as any).CompressionStream = class {};
|
||||
(global as any).DecompressionStream = class {};
|
||||
|
||||
global.Blob = class {
|
||||
constructor(public parts: any[]) {}
|
||||
stream() {
|
||||
return { pipeThrough: () => ({}) };
|
||||
}
|
||||
} as any;
|
||||
|
||||
global.Response = class {
|
||||
body = { pipeThrough: () => ({}) };
|
||||
// Return string for decompression
|
||||
text() {
|
||||
return Promise.resolve(typeof mockedResult === "string" ? mockedResult : "");
|
||||
}
|
||||
// Return ArrayBuffer for compression
|
||||
arrayBuffer() {
|
||||
if (typeof mockedResult === "string") {
|
||||
const bytes = new TextEncoder().encode(mockedResult);
|
||||
return Promise.resolve(bytes.buffer);
|
||||
}
|
||||
|
||||
return Promise.resolve(mockedResult);
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
Object.assign(global, originals);
|
||||
};
|
||||
};
|
||||
|
||||
describe("PhishingDataService", () => {
|
||||
let service: PhishingDataService;
|
||||
@@ -17,17 +76,30 @@ describe("PhishingDataService", () => {
|
||||
let taskSchedulerService: TaskSchedulerService;
|
||||
let logService: MockProxy<LogService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
const stateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
const fakeGlobalStateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
const setMockState = (state: PhishingData) => {
|
||||
stateProvider.getFake(PHISHING_DOMAINS_KEY).stateSubject.next(state);
|
||||
const setMockMeta = (state: PhishingDataMeta) => {
|
||||
fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_META_KEY).stateSubject.next(state);
|
||||
return state;
|
||||
};
|
||||
const setMockBlob = (state: PhishingDataBlob) => {
|
||||
fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
let fetchChecksumSpy: jest.SpyInstance;
|
||||
let fetchWebAddressesSpy: jest.SpyInstance;
|
||||
let fetchAndCompressSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockMeta: PhishingDataMeta = {
|
||||
checksum: "abc",
|
||||
timestamp: Date.now(),
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
const mockBlob = "http://phish.com\nhttps://badguy.net";
|
||||
const mockCompressedBlob =
|
||||
"H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA=";
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
apiService = mock<ApiService>();
|
||||
logService = mock<LogService>();
|
||||
@@ -40,54 +112,75 @@ describe("PhishingDataService", () => {
|
||||
service = new PhishingDataService(
|
||||
apiService,
|
||||
taskSchedulerService,
|
||||
stateProvider,
|
||||
fakeGlobalStateProvider,
|
||||
logService,
|
||||
platformUtilsService,
|
||||
);
|
||||
|
||||
fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum");
|
||||
fetchWebAddressesSpy = jest.spyOn(service as any, "fetchPhishingWebAddresses");
|
||||
fetchAndCompressSpy = jest.spyOn(service as any, "fetchAndCompress");
|
||||
|
||||
fetchChecksumSpy.mockResolvedValue("new-checksum");
|
||||
fetchAndCompressSpy.mockResolvedValue("compressed-blob");
|
||||
});
|
||||
|
||||
describe("initialization", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob);
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob);
|
||||
});
|
||||
|
||||
it("should perform background update", async () => {
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.x");
|
||||
jest
|
||||
.spyOn(service as any, "getNextWebAddresses")
|
||||
.mockResolvedValue({ meta: mockMeta, blob: mockBlob });
|
||||
|
||||
setMockBlob(mockBlob);
|
||||
setMockMeta(mockMeta);
|
||||
|
||||
const sub = service.update$.subscribe();
|
||||
await flushPromises();
|
||||
|
||||
const url = new URL("http://phish.com");
|
||||
const QAurl = new URL("http://phishing.testcategory.com");
|
||||
expect(await service.isPhishingWebAddress(url)).toBe(true);
|
||||
expect(await service.isPhishingWebAddress(QAurl)).toBe(true);
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPhishingWebAddress", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob);
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob);
|
||||
});
|
||||
|
||||
it("should detect a phishing web address", async () => {
|
||||
setMockState({
|
||||
webAddresses: ["phish.com", "badguy.net"],
|
||||
timestamp: Date.now(),
|
||||
checksum: "abc123",
|
||||
applicationVersion: "1.0.0",
|
||||
});
|
||||
service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]);
|
||||
|
||||
const url = new URL("http://phish.com");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should not detect a safe web address", async () => {
|
||||
setMockState({
|
||||
webAddresses: ["phish.com", "badguy.net"],
|
||||
timestamp: Date.now(),
|
||||
checksum: "abc123",
|
||||
applicationVersion: "1.0.0",
|
||||
});
|
||||
service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]);
|
||||
const url = new URL("http://safe.com");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should match against root web address", async () => {
|
||||
setMockState({
|
||||
webAddresses: ["phish.com", "badguy.net"],
|
||||
timestamp: Date.now(),
|
||||
checksum: "abc123",
|
||||
applicationVersion: "1.0.0",
|
||||
});
|
||||
service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]);
|
||||
const url = new URL("http://phish.com/about");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should not error on empty state", async () => {
|
||||
setMockState(undefined as any);
|
||||
service["_webAddressesSet"] = null;
|
||||
const url = new URL("http://phish.com/about");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
expect(result).toBe(false);
|
||||
@@ -95,64 +188,142 @@ describe("PhishingDataService", () => {
|
||||
});
|
||||
|
||||
describe("getNextWebAddresses", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob);
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob);
|
||||
});
|
||||
|
||||
it("refetches all web addresses if applicationVersion has changed", async () => {
|
||||
const prev: PhishingData = {
|
||||
webAddresses: ["a.com"],
|
||||
const prev: PhishingDataMeta = {
|
||||
timestamp: Date.now() - 60000,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("new");
|
||||
fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]);
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0");
|
||||
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
|
||||
expect(result!.webAddresses).toEqual(["d.com", "e.com"]);
|
||||
expect(result!.checksum).toBe("new");
|
||||
expect(result!.applicationVersion).toBe("2.0.0");
|
||||
expect(result!.blob).toBe("compressed-blob");
|
||||
expect(result!.meta!.checksum).toBe("new");
|
||||
expect(result!.meta!.applicationVersion).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("only updates timestamp if checksum matches", async () => {
|
||||
const prev: PhishingData = {
|
||||
webAddresses: ["a.com"],
|
||||
timestamp: Date.now() - 60000,
|
||||
it("returns null when checksum matches and cache not expired", async () => {
|
||||
const prev: PhishingDataMeta = {
|
||||
timestamp: Date.now(),
|
||||
checksum: "abc",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("abc");
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
expect(result!.webAddresses).toEqual(prev.webAddresses);
|
||||
expect(result!.checksum).toBe("abc");
|
||||
expect(result!.timestamp).not.toBe(prev.timestamp);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("patches daily domains if cache is fresh", async () => {
|
||||
const prev: PhishingData = {
|
||||
webAddresses: ["a.com"],
|
||||
timestamp: Date.now() - 60000,
|
||||
it("patches daily domains when cache is expired and checksum unchanged", async () => {
|
||||
const prev: PhishingDataMeta = {
|
||||
timestamp: 0,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
const dailyLines = ["b.com", "c.com"];
|
||||
fetchChecksumSpy.mockResolvedValue("old");
|
||||
jest.spyOn(service as any, "fetchText").mockResolvedValue(dailyLines);
|
||||
|
||||
setMockBlob(mockBlob);
|
||||
|
||||
const expectedBlob =
|
||||
"H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA=";
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
|
||||
expect(result!.blob).toBe(expectedBlob);
|
||||
expect(result!.meta!.checksum).toBe("old");
|
||||
});
|
||||
|
||||
it("fetches all domains when checksum has changed", async () => {
|
||||
const prev: PhishingDataMeta = {
|
||||
timestamp: 0,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("new");
|
||||
fetchWebAddressesSpy.mockResolvedValue(["b.com", "c.com"]);
|
||||
fetchAndCompressSpy.mockResolvedValue("new-blob");
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
expect(result!.webAddresses).toEqual(["a.com", "b.com", "c.com"]);
|
||||
expect(result!.checksum).toBe("new");
|
||||
expect(result!.blob).toBe("new-blob");
|
||||
expect(result!.meta!.checksum).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compression helpers", () => {
|
||||
let restore: () => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
restore = setupPhishingMocks("abc");
|
||||
});
|
||||
|
||||
it("fetches all domains if cache is old", async () => {
|
||||
const prev: PhishingData = {
|
||||
webAddresses: ["a.com"],
|
||||
timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("new");
|
||||
fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]);
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
expect(result!.webAddresses).toEqual(["d.com", "e.com"]);
|
||||
expect(result!.checksum).toBe("new");
|
||||
afterEach(() => {
|
||||
if (restore) {
|
||||
restore();
|
||||
}
|
||||
delete (Uint8Array as any).fromBase64;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("_compressString", () => {
|
||||
it("compresses a string to base64", async () => {
|
||||
const out = await service["_compressString"]("abc");
|
||||
expect(out).toBe("YWJj"); // base64 for 'abc'
|
||||
});
|
||||
|
||||
it("compresses using fallback on older browsers", async () => {
|
||||
const input = "abc";
|
||||
const expected = btoa(encodeURIComponent(input));
|
||||
const out = await service["_compressString"](input);
|
||||
expect(out).toBe(expected);
|
||||
});
|
||||
|
||||
it("compresses using btoa on error", async () => {
|
||||
const input = "abc";
|
||||
const expected = btoa(encodeURIComponent(input));
|
||||
const out = await service["_compressString"](input);
|
||||
expect(out).toBe(expected);
|
||||
});
|
||||
});
|
||||
describe("_decompressString", () => {
|
||||
it("decompresses a string from base64", async () => {
|
||||
const base64 = btoa("ignored");
|
||||
const out = await service["_decompressString"](base64);
|
||||
expect(out).toBe("abc");
|
||||
});
|
||||
|
||||
it("decompresses using fallback on older browsers", async () => {
|
||||
// Provide a fromBase64 implementation
|
||||
(Uint8Array as any).fromBase64 = (b64: string) => new Uint8Array([100, 101, 102]);
|
||||
|
||||
const out = await service["_decompressString"]("ignored");
|
||||
expect(out).toBe("abc");
|
||||
});
|
||||
|
||||
it("decompresses using atob on error", async () => {
|
||||
const base64 = btoa(encodeURIComponent("abc"));
|
||||
const out = await service["_decompressString"](base64);
|
||||
expect(out).toBe("abc");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("_loadBlobToMemory", () => {
|
||||
it("loads blob into memory set", async () => {
|
||||
const prevBlob = "ignored-base64";
|
||||
fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(prevBlob);
|
||||
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net");
|
||||
|
||||
await service["_loadBlobToMemory"]();
|
||||
const set = service["_webAddressesSet"] as Set<string>;
|
||||
expect(set).toBeDefined();
|
||||
expect(set.has("phish.com")).toBe(true);
|
||||
expect(set.has("badguy.net")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
EMPTY,
|
||||
first,
|
||||
firstValueFrom,
|
||||
map,
|
||||
share,
|
||||
startWith,
|
||||
Subject,
|
||||
@@ -20,11 +19,14 @@ import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bi
|
||||
|
||||
import { getPhishingResources, PhishingResourceType } from "../phishing-resources";
|
||||
|
||||
export type PhishingData = {
|
||||
webAddresses: string[];
|
||||
timestamp: number;
|
||||
/**
|
||||
* Metadata about the phishing data set
|
||||
*/
|
||||
export type PhishingDataMeta = {
|
||||
/** The last known checksum of the phishing data set */
|
||||
checksum: string;
|
||||
|
||||
/** The last time the data set was updated */
|
||||
timestamp: number;
|
||||
/**
|
||||
* We store the application version to refetch the entire dataset on a new client release.
|
||||
* This counteracts daily appends updates not removing inactive or false positive web addresses.
|
||||
@@ -32,30 +34,42 @@ export type PhishingData = {
|
||||
applicationVersion: string;
|
||||
};
|
||||
|
||||
export const PHISHING_DOMAINS_KEY = new KeyDefinition<PhishingData>(
|
||||
/**
|
||||
* The phishing data blob is a string representation of the phishing web addresses
|
||||
*/
|
||||
export type PhishingDataBlob = string;
|
||||
export type PhishingData = { meta: PhishingDataMeta; blob: PhishingDataBlob };
|
||||
|
||||
export const PHISHING_DOMAINS_META_KEY = new KeyDefinition<PhishingDataMeta>(
|
||||
PHISHING_DETECTION_DISK,
|
||||
"phishingDomains",
|
||||
"phishingDomainsMeta",
|
||||
{
|
||||
deserializer: (value: PhishingData) =>
|
||||
value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" },
|
||||
deserializer: (value: PhishingDataMeta) => {
|
||||
return {
|
||||
checksum: value?.checksum ?? "",
|
||||
timestamp: value?.timestamp ?? 0,
|
||||
applicationVersion: value?.applicationVersion ?? "",
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition<string>(
|
||||
PHISHING_DETECTION_DISK,
|
||||
"phishingDomainsBlob",
|
||||
{
|
||||
deserializer: (value: string) => value ?? "",
|
||||
},
|
||||
);
|
||||
|
||||
/** Coordinates fetching, caching, and patching of known phishing web addresses */
|
||||
export class PhishingDataService {
|
||||
private _testWebAddresses = this.getTestWebAddresses();
|
||||
private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY);
|
||||
private _webAddresses$ = this._cachedState.state$.pipe(
|
||||
map(
|
||||
(state) =>
|
||||
new Set(
|
||||
(state?.webAddresses?.filter((line) => line.trim().length > 0) ?? []).concat(
|
||||
this._testWebAddresses,
|
||||
"phishing.testcategory.com", // Included for QA to test in prod
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod
|
||||
private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY);
|
||||
private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY);
|
||||
|
||||
// In-memory set loaded from blob for fast lookups without reading large storage repeatedly
|
||||
private _webAddressesSet: Set<string> | null = null;
|
||||
|
||||
// How often are new web addresses added to the remote?
|
||||
readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -64,10 +78,11 @@ export class PhishingDataService {
|
||||
update$ = this._triggerUpdate$.pipe(
|
||||
startWith(undefined), // Always emit once
|
||||
switchMap(() =>
|
||||
this._cachedState.state$.pipe(
|
||||
this._phishingMetaState.state$.pipe(
|
||||
first(), // Only take the first value to avoid an infinite loop when updating the cache below
|
||||
tap((cachedState) => {
|
||||
void this._backgroundUpdate(cachedState);
|
||||
tap((metaState) => {
|
||||
// Perform any updates in the background if needed
|
||||
void this._backgroundUpdate(metaState);
|
||||
}),
|
||||
catchError((err: unknown) => {
|
||||
this.logService.error("[PhishingDataService] Background update failed to start.", err);
|
||||
@@ -86,6 +101,7 @@ export class PhishingDataService {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private resourceType: PhishingResourceType = PhishingResourceType.Links,
|
||||
) {
|
||||
this.logService.debug("[PhishingDataService] Initializing service...");
|
||||
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => {
|
||||
this._triggerUpdate$.next();
|
||||
});
|
||||
@@ -93,6 +109,7 @@ export class PhishingDataService {
|
||||
ScheduledTaskNames.phishingDomainUpdate,
|
||||
this.UPDATE_INTERVAL_DURATION,
|
||||
);
|
||||
void this._loadBlobToMemory();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,12 +119,17 @@ export class PhishingDataService {
|
||||
* @returns True if the URL is a known phishing web address, false otherwise
|
||||
*/
|
||||
async isPhishingWebAddress(url: URL): Promise<boolean> {
|
||||
// Use domain (hostname) matching for domain resources, and link matching for links resources
|
||||
const entries = await firstValueFrom(this._webAddresses$);
|
||||
if (!this._webAddressesSet) {
|
||||
this.logService.debug("[PhishingDataService] Set not loaded; skipping check");
|
||||
return false;
|
||||
}
|
||||
|
||||
const set = this._webAddressesSet!;
|
||||
const resource = getPhishingResources(this.resourceType);
|
||||
if (resource && resource.match) {
|
||||
for (const entry of entries) {
|
||||
|
||||
// Custom matcher per resource
|
||||
if (resource && resource?.match) {
|
||||
for (const entry of set) {
|
||||
if (resource.match(url, entry)) {
|
||||
return true;
|
||||
}
|
||||
@@ -115,54 +137,59 @@ export class PhishingDataService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default/domain behavior: exact hostname match as a fallback
|
||||
return entries.has(url.hostname);
|
||||
// Default set-based lookup
|
||||
return set.has(url.hostname);
|
||||
}
|
||||
|
||||
async getNextWebAddresses(prev: PhishingData | null): Promise<PhishingData | null> {
|
||||
prev = prev ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" };
|
||||
const timestamp = Date.now();
|
||||
const prevAge = timestamp - prev.timestamp;
|
||||
this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`);
|
||||
async getNextWebAddresses(
|
||||
previous: PhishingDataMeta | null,
|
||||
): Promise<Partial<PhishingData> | null> {
|
||||
const prevMeta = previous ?? { timestamp: 0, checksum: "", applicationVersion: "" };
|
||||
const now = Date.now();
|
||||
|
||||
// Updates to check
|
||||
const applicationVersion = await this.platformUtilsService.getApplicationVersion();
|
||||
|
||||
// If checksum matches, return existing data with new timestamp & version
|
||||
const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType);
|
||||
if (remoteChecksum && prev.checksum === remoteChecksum) {
|
||||
this.logService.info(
|
||||
`[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`,
|
||||
);
|
||||
return { ...prev, timestamp, applicationVersion };
|
||||
}
|
||||
// Checksum is different, data needs to be updated.
|
||||
|
||||
// Approach 1: Fetch only new web addresses and append
|
||||
const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION;
|
||||
if (isOneDayOldMax && applicationVersion === prev.applicationVersion) {
|
||||
const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl;
|
||||
const dailyWebAddresses: string[] =
|
||||
await this.fetchPhishingWebAddresses(webAddressesTodayUrl);
|
||||
this.logService.info(
|
||||
`[PhishingDataService] ${dailyWebAddresses.length} new phishing web addresses added`,
|
||||
);
|
||||
// Logic checks
|
||||
const appVersionChanged = applicationVersion !== prevMeta.applicationVersion;
|
||||
const masterChecksumChanged = remoteChecksum !== prevMeta.checksum;
|
||||
|
||||
// Check for full updated
|
||||
if (masterChecksumChanged || appVersionChanged) {
|
||||
this.logService.info("[PhishingDataService] Checksum or version changed; Fetching ALL.");
|
||||
const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl;
|
||||
const blob = await this.fetchAndCompress(remoteUrl);
|
||||
return {
|
||||
webAddresses: prev.webAddresses.concat(dailyWebAddresses),
|
||||
checksum: remoteChecksum,
|
||||
timestamp,
|
||||
applicationVersion,
|
||||
blob,
|
||||
meta: { checksum: remoteChecksum, timestamp: now, applicationVersion },
|
||||
};
|
||||
}
|
||||
|
||||
// Approach 2: Fetch all web addresses
|
||||
const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl;
|
||||
const remoteWebAddresses = await this.fetchPhishingWebAddresses(remoteUrl);
|
||||
return {
|
||||
webAddresses: remoteWebAddresses,
|
||||
timestamp,
|
||||
checksum: remoteChecksum,
|
||||
applicationVersion,
|
||||
};
|
||||
// Check for daily file
|
||||
const isCacheExpired = now - prevMeta.timestamp > this.UPDATE_INTERVAL_DURATION;
|
||||
|
||||
if (isCacheExpired) {
|
||||
this.logService.info("[PhishingDataService] Daily cache expired; Fetching TODAY's");
|
||||
const url = getPhishingResources(this.resourceType)!.todayUrl;
|
||||
const newLines = await this.fetchText(url);
|
||||
const prevBlob = (await firstValueFrom(this._phishingBlobState.state$)) ?? "";
|
||||
const oldText = prevBlob ? await this._decompressString(prevBlob) : "";
|
||||
|
||||
// Join the new lines to the existing list
|
||||
const combined = (oldText ? oldText + "\n" : "") + newLines.join("\n");
|
||||
|
||||
return {
|
||||
blob: await this._compressString(combined),
|
||||
meta: {
|
||||
checksum: remoteChecksum,
|
||||
timestamp: now, // Reset the timestamp
|
||||
applicationVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) {
|
||||
@@ -173,8 +200,24 @@ export class PhishingDataService {
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
private async fetchAndCompress(url: string): Promise<string> {
|
||||
const response = await this.apiService.nativeFetch(new Request(url));
|
||||
if (!response.ok) {
|
||||
throw new Error("Fetch failed");
|
||||
}
|
||||
|
||||
private async fetchPhishingWebAddresses(url: string) {
|
||||
const downloadStream = response.body!;
|
||||
// Pipe through CompressionStream while it's downloading
|
||||
const compressedStream = downloadStream.pipeThrough(new CompressionStream("gzip"));
|
||||
// Convert to ArrayBuffer
|
||||
const buffer = await new Response(compressedStream).arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
// Return as Base64 for storage
|
||||
return (bytes as any).toBase64 ? (bytes as any).toBase64() : this._uint8ToBase64Fallback(bytes);
|
||||
}
|
||||
|
||||
private async fetchText(url: string) {
|
||||
const response = await this.apiService.nativeFetch(new Request(url));
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -202,10 +245,9 @@ export class PhishingDataService {
|
||||
}
|
||||
|
||||
// Runs the update flow in the background and retries up to 3 times on failure
|
||||
private async _backgroundUpdate(prev: PhishingData | null): Promise<void> {
|
||||
this.logService.info(`[PhishingDataService] Update triggered...`);
|
||||
const phishingData = prev ?? {
|
||||
webAddresses: [],
|
||||
private async _backgroundUpdate(previous: PhishingDataMeta | null): Promise<void> {
|
||||
this.logService.info(`[PhishingDataService] Update web addresses triggered...`);
|
||||
const phishingMeta: PhishingDataMeta = previous ?? {
|
||||
timestamp: 0,
|
||||
checksum: "",
|
||||
applicationVersion: "",
|
||||
@@ -217,15 +259,22 @@ export class PhishingDataService {
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const next = await this.getNextWebAddresses(phishingData);
|
||||
if (next) {
|
||||
await this._cachedState.update(() => next);
|
||||
|
||||
// Performance logging
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logService.info(`[PhishingDataService] cache updated in ${elapsed}ms`);
|
||||
const next = await this.getNextWebAddresses(phishingMeta);
|
||||
if (!next) {
|
||||
return; // No update needed
|
||||
}
|
||||
return;
|
||||
|
||||
if (next.meta) {
|
||||
await this._phishingMetaState.update(() => next!.meta!);
|
||||
}
|
||||
if (next.blob) {
|
||||
await this._phishingBlobState.update(() => next!.blob!);
|
||||
await this._loadBlobToMemory();
|
||||
}
|
||||
|
||||
// Performance logging
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logService.info(`[PhishingDataService] Phishing data cache updated in ${elapsed}ms`);
|
||||
} catch (err) {
|
||||
this.logService.error(
|
||||
`[PhishingDataService] Unable to update web addresses. Attempt ${attempt}.`,
|
||||
@@ -243,4 +292,87 @@ export class PhishingDataService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [FIXME] Move compression helpers to a shared utils library
|
||||
// to separate from phishing data service.
|
||||
// ------------------------- Blob and Compression Handling -------------------------
|
||||
private async _compressString(input: string): Promise<string> {
|
||||
try {
|
||||
const stream = new Blob([input]).stream().pipeThrough(new CompressionStream("gzip"));
|
||||
|
||||
const compressedBuffer = await new Response(stream).arrayBuffer();
|
||||
const bytes = new Uint8Array(compressedBuffer);
|
||||
|
||||
// Modern browsers support direct toBase64 conversion
|
||||
// For older support, use fallback
|
||||
return (bytes as any).toBase64
|
||||
? (bytes as any).toBase64()
|
||||
: this._uint8ToBase64Fallback(bytes);
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Compression failed", err);
|
||||
return btoa(encodeURIComponent(input));
|
||||
}
|
||||
}
|
||||
|
||||
private async _decompressString(base64: string): Promise<string> {
|
||||
try {
|
||||
// Modern browsers support direct toBase64 conversion
|
||||
// For older support, use fallback
|
||||
const bytes = (Uint8Array as any).fromBase64
|
||||
? (Uint8Array as any).fromBase64(base64)
|
||||
: this._base64ToUint8Fallback(base64);
|
||||
if (bytes == null) {
|
||||
throw new Error("Base64 decoding resulted in null");
|
||||
}
|
||||
const byteResponse = new Response(bytes);
|
||||
if (!byteResponse.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
const stream = byteResponse.body.pipeThrough(new DecompressionStream("gzip"));
|
||||
const streamResponse = new Response(stream);
|
||||
return await streamResponse.text();
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Decompression failed", err);
|
||||
return decodeURIComponent(atob(base64));
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load compressed newline blob into an in-memory Set for fast lookups
|
||||
private async _loadBlobToMemory(): Promise<void> {
|
||||
this.logService.debug("[PhishingDataService] Loading data blob into memory...");
|
||||
try {
|
||||
const blobBase64 = await firstValueFrom(this._phishingBlobState.state$);
|
||||
if (!blobBase64) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await this._decompressString(blobBase64);
|
||||
// Split and filter
|
||||
const lines = text.split(/\r?\n/);
|
||||
const newWebAddressesSet = new Set(lines);
|
||||
|
||||
// Add test addresses
|
||||
this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a));
|
||||
this._webAddressesSet = new Set(newWebAddressesSet);
|
||||
this.logService.info(
|
||||
`[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Failed to load blob into memory", err);
|
||||
}
|
||||
}
|
||||
private _uint8ToBase64Fallback(bytes: Uint8Array): string {
|
||||
const CHUNK_SIZE = 0x8000; // 32KB chunks
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
||||
const chunk = bytes.subarray(i, i + CHUNK_SIZE);
|
||||
binary += String.fromCharCode.apply(null, chunk as any);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
private _base64ToUint8Fallback(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +537,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AccountService,
|
||||
BillingAccountProfileStateService,
|
||||
ConfigService,
|
||||
LogService,
|
||||
OrganizationService,
|
||||
PlatformUtilsService,
|
||||
StateProvider,
|
||||
|
||||
@@ -405,4 +405,42 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAssignCollections$", () => {
|
||||
it("emits true when user has organizations and editable collections", (done) => {
|
||||
jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true));
|
||||
jest
|
||||
.spyOn(component["collectionService"], "decryptedCollections$")
|
||||
.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when user has no organizations", (done) => {
|
||||
jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(false));
|
||||
jest
|
||||
.spyOn(component["collectionService"], "decryptedCollections$")
|
||||
.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when all collections are read-only", (done) => {
|
||||
jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true));
|
||||
jest
|
||||
.spyOn(component["collectionService"], "decryptedCollections$")
|
||||
.mockReturnValue(of([{ id: "col-1", readOnly: true }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,15 @@
|
||||
<button type="button" bitMenuItem (click)="clone(cipher)">
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
@if (canAssignCollections$ | async) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="conditionallyNavigateToAssignCollections(cipher)"
|
||||
>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" bitMenuItem (click)="unarchive(cipher)">
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
|
||||
135
apps/browser/src/vault/popup/settings/archive.component.spec.ts
Normal file
135
apps/browser/src/vault/popup/settings/archive.component.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { ArchiveComponent } from "./archive.component";
|
||||
|
||||
describe("ArchiveComponent", () => {
|
||||
let component: ArchiveComponent;
|
||||
|
||||
let hasOrganizations: jest.Mock;
|
||||
let decryptedCollections$: jest.Mock;
|
||||
let navigate: jest.Mock;
|
||||
let showPasswordPrompt: jest.Mock;
|
||||
|
||||
beforeAll(async () => {
|
||||
navigate = jest.fn();
|
||||
showPasswordPrompt = jest.fn().mockResolvedValue(true);
|
||||
hasOrganizations = jest.fn();
|
||||
decryptedCollections$ = jest.fn();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: Router, useValue: { navigate } },
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: { activeAccount$: new BehaviorSubject({ id: "user-id" }) },
|
||||
},
|
||||
{ provide: PasswordRepromptService, useValue: { showPasswordPrompt } },
|
||||
{ provide: OrganizationService, useValue: { hasOrganizations } },
|
||||
{ provide: CollectionService, useValue: { decryptedCollections$ } },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: CipherService, useValue: mock<CipherService>() },
|
||||
{ provide: CipherArchiveService, useValue: mock<CipherArchiveService>() },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(ArchiveComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("canAssignCollections$", () => {
|
||||
it("emits true when user has organizations and editable collections", (done) => {
|
||||
hasOrganizations.mockReturnValue(of(true));
|
||||
decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when user has no organizations", (done) => {
|
||||
hasOrganizations.mockReturnValue(of(false));
|
||||
decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when all collections are read-only", (done) => {
|
||||
hasOrganizations.mockReturnValue(of(true));
|
||||
decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: true }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditionallyNavigateToAssignCollections", () => {
|
||||
const mockCipher = {
|
||||
id: "cipher-1",
|
||||
reprompt: 0,
|
||||
} as CipherViewLike;
|
||||
|
||||
it("navigates to assign-collections when reprompt is not required", async () => {
|
||||
await component.conditionallyNavigateToAssignCollections(mockCipher);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/assign-collections"], {
|
||||
queryParams: { cipherId: "cipher-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts for password when reprompt is required", async () => {
|
||||
const cipherWithReprompt = { ...mockCipher, reprompt: 1 };
|
||||
|
||||
await component.conditionallyNavigateToAssignCollections(
|
||||
cipherWithReprompt as CipherViewLike,
|
||||
);
|
||||
|
||||
expect(showPasswordPrompt).toHaveBeenCalled();
|
||||
expect(navigate).toHaveBeenCalledWith(["/assign-collections"], {
|
||||
queryParams: { cipherId: "cipher-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not navigate when password prompt is cancelled", async () => {
|
||||
const cipherWithReprompt = { ...mockCipher, reprompt: 1 };
|
||||
showPasswordPrompt.mockResolvedValueOnce(false);
|
||||
|
||||
await component.conditionallyNavigateToAssignCollections(
|
||||
cipherWithReprompt as CipherViewLike,
|
||||
);
|
||||
|
||||
expect(showPasswordPrompt).toHaveBeenCalled();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, inject } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom, map, Observable, startWith, switchMap } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Observable, startWith, switchMap } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -71,6 +73,9 @@ export class ArchiveComponent {
|
||||
private i18nService = inject(I18nService);
|
||||
private cipherArchiveService = inject(CipherArchiveService);
|
||||
private passwordRepromptService = inject(PasswordRepromptService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private collectionService = inject(CollectionService);
|
||||
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
protected archivedCiphers$ = this.userId$.pipe(
|
||||
@@ -87,6 +92,20 @@ export class ArchiveComponent {
|
||||
startWith(true),
|
||||
);
|
||||
|
||||
protected canAssignCollections$ = this.userId$.pipe(
|
||||
switchMap((userId) => {
|
||||
return combineLatest([
|
||||
this.organizationService.hasOrganizations(userId),
|
||||
this.collectionService.decryptedCollections$(userId),
|
||||
]).pipe(
|
||||
map(([hasOrgs, collections]) => {
|
||||
const canEditCollections = collections.some((c) => !c.readOnly);
|
||||
return hasOrgs && canEditCollections;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
protected showSubscriptionEndedMessaging$ = this.userId$.pipe(
|
||||
switchMap((userId) => this.cipherArchiveService.showSubscriptionEndedMessaging$(userId)),
|
||||
);
|
||||
@@ -187,6 +206,17 @@ export class ArchiveComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/** Prompts for password when necessary then navigates to the assign collections route */
|
||||
async conditionallyNavigateToAssignCollections(cipher: CipherViewLike) {
|
||||
if (cipher.reprompt && !(await this.passwordRepromptService.showPasswordPrompt())) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(["/assign-collections"], {
|
||||
queryParams: { cipherId: cipher.id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is able to interact with the cipher
|
||||
* (password re-prompt / decryption failure checks).
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</bit-callout>
|
||||
|
||||
@if (SendUIRefresh$ | async) {
|
||||
<div class="tw-mb-4 tw-max-w-md">
|
||||
<div class="tw-mb-4">
|
||||
<bit-search
|
||||
[(ngModel)]="searchText"
|
||||
[placeholder]="'searchSends' | i18n"
|
||||
|
||||
@@ -142,4 +142,45 @@ describe("VaultCipherRowComponent", () => {
|
||||
expect(overlayContent).not.toContain('appcopyfield="password"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("showAssignToCollections", () => {
|
||||
let archivedCipher: CipherView;
|
||||
|
||||
beforeEach(() => {
|
||||
archivedCipher = new CipherView();
|
||||
archivedCipher.id = "cipher-1";
|
||||
archivedCipher.name = "Test Cipher";
|
||||
archivedCipher.type = CipherType.Login;
|
||||
archivedCipher.organizationId = "org-1";
|
||||
archivedCipher.deletedDate = null;
|
||||
archivedCipher.archivedDate = new Date();
|
||||
|
||||
component.cipher = archivedCipher;
|
||||
component.organizations = [{ id: "org-1" } as any];
|
||||
component.canAssignCollections = true;
|
||||
component.disabled = false;
|
||||
});
|
||||
|
||||
it("returns true when cipher is archived and conditions are met", () => {
|
||||
expect(component["showAssignToCollections"]).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when cipher is deleted", () => {
|
||||
archivedCipher.deletedDate = new Date();
|
||||
|
||||
expect(component["showAssignToCollections"]).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user cannot assign collections", () => {
|
||||
component.canAssignCollections = false;
|
||||
|
||||
expect(component["showAssignToCollections"]).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there are no organizations", () => {
|
||||
component.organizations = [];
|
||||
|
||||
expect(component["showAssignToCollections"]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,11 +217,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return CipherViewLikeUtils.decryptionFailure(this.cipher);
|
||||
}
|
||||
|
||||
// Do Not show Assign to Collections option if item is archived
|
||||
protected get showAssignToCollections() {
|
||||
if (CipherViewLikeUtils.isArchived(this.cipher)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.organizations?.length &&
|
||||
this.canAssignCollections &&
|
||||
|
||||
@@ -12647,5 +12647,8 @@
|
||||
},
|
||||
"youHavePremium": {
|
||||
"message": "You have Premium"
|
||||
},
|
||||
"emailProtected": {
|
||||
"message": "Email protected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -54,6 +55,8 @@ describe("PhishingDetectionSettingsService", () => {
|
||||
usePhishingBlocker: true,
|
||||
});
|
||||
|
||||
const mockLogService = mock<LogService>();
|
||||
|
||||
const mockUserId = "mock-user-id" as UserId;
|
||||
const account = mock<Account>({ id: mockUserId });
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
@@ -85,6 +88,7 @@ describe("PhishingDetectionSettingsService", () => {
|
||||
mockAccountService,
|
||||
mockBillingService,
|
||||
mockConfigService,
|
||||
mockLogService,
|
||||
mockOrganizationService,
|
||||
mockPlatformService,
|
||||
stateProvider,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { combineLatest, Observable, of, switchMap } from "rxjs";
|
||||
import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators";
|
||||
import { catchError, distinctUntilChanged, map, shareReplay, tap } from "rxjs/operators";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
@@ -9,6 +9,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
@@ -32,27 +33,47 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
|
||||
private accountService: AccountService,
|
||||
private billingService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
private logService: LogService,
|
||||
private organizationService: OrganizationService,
|
||||
private platformService: PlatformUtilsService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.logService.debug(`[PhishingDetectionSettingsService] Initializing service...`);
|
||||
this.available$ = this.buildAvailablePipeline$().pipe(
|
||||
distinctUntilChanged(),
|
||||
tap((available) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection available: ${available}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
this.enabled$ = this.buildEnabledPipeline$().pipe(
|
||||
distinctUntilChanged(),
|
||||
tap((enabled) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection enabled: ${{ enabled }}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
this.on$ = combineLatest([this.available$, this.enabled$]).pipe(
|
||||
map(([available, enabled]) => available && enabled),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
tap((on) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection is on: ${{ on }}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
}
|
||||
|
||||
async setEnabled(userId: UserId, enabled: boolean): Promise<void> {
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Setting phishing detection enabled: ${{ enabled, userId }}`,
|
||||
);
|
||||
await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled);
|
||||
}
|
||||
|
||||
@@ -64,6 +85,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
|
||||
private buildAvailablePipeline$(): Observable<boolean> {
|
||||
// Phishing detection is unavailable on Safari due to platform limitations.
|
||||
if (this.platformService.isSafari()) {
|
||||
this.logService.warning(
|
||||
`[PhishingDetectionSettingsService] Phishing detection is unavailable on Safari due to platform limitations`,
|
||||
);
|
||||
return of(false);
|
||||
}
|
||||
|
||||
@@ -97,6 +121,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
|
||||
if (!account) {
|
||||
return of(false);
|
||||
}
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Refreshing phishing detection enabled state`,
|
||||
);
|
||||
return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id);
|
||||
}),
|
||||
map((enabled) => enabled ?? true),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendResponse } from "../response/send.response";
|
||||
|
||||
@@ -10,6 +11,7 @@ export class SendData {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: string;
|
||||
notes: string;
|
||||
file: SendFileData;
|
||||
@@ -33,6 +35,7 @@ export class SendData {
|
||||
this.id = response.id;
|
||||
this.accessId = response.accessId;
|
||||
this.type = response.type;
|
||||
this.authType = response.authType;
|
||||
this.name = response.name;
|
||||
this.notes = response.notes;
|
||||
this.key = response.key;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "../../../../platform/services/container.service";
|
||||
import { UserKey } from "../../../../types/key";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
|
||||
@@ -25,6 +26,7 @@ describe("Send", () => {
|
||||
id: "id",
|
||||
accessId: "accessId",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.None,
|
||||
name: "encName",
|
||||
notes: "encNotes",
|
||||
text: {
|
||||
@@ -55,6 +57,7 @@ describe("Send", () => {
|
||||
id: null,
|
||||
accessId: null,
|
||||
type: undefined,
|
||||
authType: undefined,
|
||||
name: null,
|
||||
notes: null,
|
||||
text: undefined,
|
||||
@@ -78,6 +81,7 @@ describe("Send", () => {
|
||||
id: "id",
|
||||
accessId: "accessId",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.None,
|
||||
name: { encryptedString: "encName", encryptionType: 0 },
|
||||
notes: { encryptedString: "encNotes", encryptionType: 0 },
|
||||
text: {
|
||||
@@ -107,6 +111,7 @@ describe("Send", () => {
|
||||
send.id = "id";
|
||||
send.accessId = "accessId";
|
||||
send.type = SendType.Text;
|
||||
send.authType = AuthType.None;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.text = text;
|
||||
@@ -145,6 +150,7 @@ describe("Send", () => {
|
||||
name: "name",
|
||||
notes: "notes",
|
||||
type: 0,
|
||||
authType: 2,
|
||||
key: expect.anything(),
|
||||
cryptoKey: "cryptoKey",
|
||||
file: expect.anything(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import Domain from "../../../../platform/models/domain/domain-base";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
import { SendView } from "../view/send.view";
|
||||
@@ -19,6 +20,7 @@ export class Send extends Domain {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: EncString;
|
||||
notes: EncString;
|
||||
file: SendFile;
|
||||
@@ -54,6 +56,7 @@ export class Send extends Domain {
|
||||
);
|
||||
|
||||
this.type = obj.type;
|
||||
this.authType = obj.authType;
|
||||
this.maxAccessCount = obj.maxAccessCount;
|
||||
this.accessCount = obj.accessCount;
|
||||
this.password = obj.password;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
@@ -9,6 +10,7 @@ export class SendResponse extends BaseResponse {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: string;
|
||||
notes: string;
|
||||
file: SendFileApi;
|
||||
@@ -29,6 +31,7 @@ export class SendResponse extends BaseResponse {
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.accessId = this.getResponseProperty("AccessId");
|
||||
this.type = this.getResponseProperty("Type");
|
||||
this.authType = this.getResponseProperty("AuthType");
|
||||
this.name = this.getResponseProperty("Name");
|
||||
this.notes = this.getResponseProperty("Notes");
|
||||
this.key = this.getResponseProperty("Key");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { View } from "../../../../models/view/view";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { DeepJsonify } from "../../../../types/deep-jsonify";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { Send } from "../domain/send";
|
||||
|
||||
@@ -18,6 +19,7 @@ export class SendView implements View {
|
||||
key: Uint8Array;
|
||||
cryptoKey: SymmetricCryptoKey;
|
||||
type: SendType = null;
|
||||
authType: AuthType = null;
|
||||
text = new SendTextView();
|
||||
file = new SendFileView();
|
||||
maxAccessCount?: number = null;
|
||||
@@ -38,6 +40,7 @@ export class SendView implements View {
|
||||
this.id = s.id;
|
||||
this.accessId = s.accessId;
|
||||
this.type = s.type;
|
||||
this.authType = s.authType;
|
||||
this.maxAccessCount = s.maxAccessCount;
|
||||
this.accessCount = s.accessCount;
|
||||
this.revisionDate = s.revisionDate;
|
||||
|
||||
12
libs/common/src/tools/send/types/auth-type.ts
Normal file
12
libs/common/src/tools/send/types/auth-type.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** An type of auth necessary to access a Send */
|
||||
export const AuthType = Object.freeze({
|
||||
/** Send requires email OTP verification */
|
||||
Email: 0,
|
||||
/** Send requires a password */
|
||||
Password: 1,
|
||||
/** Send requires no auth */
|
||||
None: 2,
|
||||
} as const);
|
||||
|
||||
/** An type of auth necessary to access a Send */
|
||||
export type AuthType = (typeof AuthType)[keyof typeof AuthType];
|
||||
@@ -33,14 +33,16 @@
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "disabled" | i18n }}</span>
|
||||
}
|
||||
@if (s.password) {
|
||||
@if (s.authType !== authType.None) {
|
||||
@let titleKey =
|
||||
s.authType === authType.Email ? "emailProtected" : "passwordProtected";
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
class="bwi bwi-lock"
|
||||
appStopProp
|
||||
title="{{ 'password' | i18n }}"
|
||||
title="{{ titleKey | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "password" | i18n }}</span>
|
||||
<span class="tw-sr-only">{{ titleKey | i18n }}</span>
|
||||
}
|
||||
@if (s.maxAccessCountReached) {
|
||||
<i
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { TableDataSource, I18nMockService } from "@bitwarden/components";
|
||||
|
||||
@@ -13,6 +14,7 @@ function createMockSend(id: number, overrides: Partial<SendView> = {}): SendView
|
||||
send.id = `send-${id}`;
|
||||
send.name = "My Send";
|
||||
send.type = SendType.Text;
|
||||
send.authType = AuthType.None;
|
||||
send.deletionDate = new Date("2030-01-01T12:00:00Z");
|
||||
send.password = null as any;
|
||||
|
||||
@@ -34,21 +36,29 @@ dataSource.data = [
|
||||
createMockSend(2, {
|
||||
name: "Password Protected Send",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.Password,
|
||||
password: "123",
|
||||
}),
|
||||
createMockSend(3, {
|
||||
name: "Email Protected Send",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.Email,
|
||||
emails: ["ckent@dailyplanet.com"],
|
||||
}),
|
||||
createMockSend(4, {
|
||||
name: "Disabled Send",
|
||||
type: SendType.Text,
|
||||
disabled: true,
|
||||
}),
|
||||
createMockSend(4, {
|
||||
createMockSend(5, {
|
||||
name: "Expired Send",
|
||||
type: SendType.File,
|
||||
expirationDate: new Date("2025-12-01T00:00:00Z"),
|
||||
}),
|
||||
createMockSend(5, {
|
||||
createMockSend(6, {
|
||||
name: "Max Access Reached",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.Password,
|
||||
maxAccessCount: 5,
|
||||
accessCount: 5,
|
||||
password: "123",
|
||||
@@ -69,7 +79,8 @@ export default {
|
||||
deletionDate: "Deletion Date",
|
||||
options: "Options",
|
||||
disabled: "Disabled",
|
||||
password: "Password",
|
||||
passwordProtected: "Password protected",
|
||||
emailProtected: "Email protected",
|
||||
maxAccessCountReached: "Max access count reached",
|
||||
expired: "Expired",
|
||||
pendingDeletion: "Pending deletion",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import {
|
||||
BadgeModule,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
})
|
||||
export class SendTableComponent {
|
||||
protected readonly sendType = SendType;
|
||||
protected readonly authType = AuthType;
|
||||
|
||||
/**
|
||||
* The data source containing the Send items to display in the table.
|
||||
|
||||
Reference in New Issue
Block a user