From 078db6fef20ab71fcd6e50157bda1ff5b3729afb Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 24 Feb 2026 17:36:06 +0100 Subject: [PATCH] Fix domain list host matching and add regressions --- src/shared/utils.ts | 49 +++++++++++++-- tests/e2e/puppeteer-extension.test.ts | 79 +++++++++++++++++++++++ tests/utils.test.ts | 91 +++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 tests/utils.test.ts diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 4f1d2fa0..b3df805b 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -6,6 +6,36 @@ export const DOMAIN_LIST_MODE = { whiteList: "Whitelist - disabled on all websites, enabled on specific sites", }; +function normalizeDomainHost(domainOrUrl: string): string | undefined { + if (typeof domainOrUrl !== "string") { + return undefined; + } + + const trimmed = domainOrUrl.trim(); + if (!trimmed) { + return undefined; + } + + const parseHostName = (value: string): string | undefined => { + try { + return new URL(value).hostname; + } catch { + return undefined; + } + }; + + let hostName = parseHostName(trimmed); + if (!hostName) { + hostName = parseHostName(`http://${trimmed}`); + } + if (!hostName) { + return undefined; + } + + const normalized = hostName.toLowerCase().replace(/\.+$/, ""); + return normalized || undefined; +} + /** * Extracts the domain from a URL. * @@ -27,7 +57,8 @@ export async function isDomainOnList( settings: SettingsManager, domainURL: string, ): Promise { - if (!domainURL) { + const normalizedDomain = normalizeDomainHost(domainURL); + if (!normalizedDomain) { return false; } try { @@ -36,7 +67,8 @@ export async function isDomainOnList( throw new Error("The domain list is not an array."); } for (let i = 0; i < domainList.length; i++) { - if (domainURL.match(domainList[i] as string)) { + const listDomain = normalizeDomainHost(String(domainList[i])); + if (listDomain && normalizedDomain === listDomain) { return true; } } @@ -54,12 +86,16 @@ export async function addDomainToList( settings: SettingsManager, domainURL: string, ): Promise { + const normalizedDomain = normalizeDomainHost(domainURL); + if (!normalizedDomain) { + return; + } try { const domainList = await settings.get(SETTINGS_DOMAIN_BLACKLIST); if (!Array.isArray(domainList)) { throw new Error("The domain list is not an array."); } - domainList.push(domainURL); + domainList.push(normalizedDomain); settings.set(SETTINGS_DOMAIN_BLACKLIST, domainList); } catch (error: unknown) { console.error(`Error adding domain to list: ${getErrorMessage(error)}`); @@ -73,13 +109,18 @@ export async function removeDomainFromList( settings: SettingsManager, domainURL: string, ): Promise { + const normalizedDomain = normalizeDomainHost(domainURL); + if (!normalizedDomain) { + return; + } try { const domainList = await settings.get(SETTINGS_DOMAIN_BLACKLIST); if (!Array.isArray(domainList)) { throw new Error("The domain list is not an array."); } for (let i = 0; i < domainList.length; i++) { - if (domainURL.match(domainList[i] as string)) { + const listDomain = normalizeDomainHost(String(domainList[i])); + if (listDomain && normalizedDomain === listDomain) { domainList.splice(i, 1); settings.set(SETTINGS_DOMAIN_BLACKLIST, domainList); break; diff --git a/tests/e2e/puppeteer-extension.test.ts b/tests/e2e/puppeteer-extension.test.ts index 51585db1..992e2bb1 100644 --- a/tests/e2e/puppeteer-extension.test.ts +++ b/tests/e2e/puppeteer-extension.test.ts @@ -1,8 +1,10 @@ import puppeteer, { Browser, Page, WebWorker } from "puppeteer"; import path from "path"; +import { createServer, Server } from "http"; import { KEY_ENABLED_LANGUAGES, KEY_FALLBACK_LANGUAGE, + KEY_DOMAIN_LIST_MODE, KEY_LANGUAGE, KEY_INLINE_SUGGESTION, KEY_MIN_WORD_LENGTH_TO_PREDICT, @@ -363,6 +365,8 @@ describe("Chrome Extension E2E Test", () => { let browser: Browser; let page: Page; let worker: WebWorker; + let domainTestServer: Server; + let domainTestUrl: string; beforeAll(async () => { const launchArgs = [ @@ -392,6 +396,23 @@ describe("Chrome Extension E2E Test", () => { { timeout: 30000 }, ); worker = (await serviceWorkerTarget.worker())!; + + domainTestServer = createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end("

domain test page

"); + }); + await new Promise((resolve, reject) => { + domainTestServer.once("error", reject); + domainTestServer.listen(0, "127.0.0.1", () => { + domainTestServer.off("error", reject); + resolve(); + }); + }); + const address = domainTestServer.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to start domain test server."); + } + domainTestUrl = `http://localhost:${address.port}/`; }, 20000); beforeEach(async () => { @@ -406,6 +427,17 @@ describe("Chrome Extension E2E Test", () => { }); afterAll(async () => { + if (domainTestServer?.listening) { + await new Promise((resolve, reject) => { + domainTestServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } await browser.close(); }); @@ -436,6 +468,53 @@ describe("Chrome Extension E2E Test", () => { expect(popupPage).toBeDefined(); }, 5000); + test("Domain whitelist matches exact host and ignores invalid patterns", async () => { + await page.goto(domainTestUrl, { waitUntil: "domcontentloaded" }); + await page.bringToFront(); + + await setSettingAndWait(worker!, "enable", true); + await setSettingAndWait(worker!, KEY_DOMAIN_LIST_MODE, "whiteList"); + await setSettingAndWait(worker!, "domainBlackList", ["[", "localhost"]); + + let popupPage: Page | null = null; + try { + const existingPopupPages = await Promise.all( + browser + .targets() + .filter( + (target) => + target.type() === "page" && target.url().endsWith("popup.html"), + ) + .map((target) => target.page()), + ); + for (const existingPopupPage of existingPopupPages) { + if (existingPopupPage && !existingPopupPage.isClosed()) { + await existingPopupPage.close(); + } + } + + await worker!.evaluate("chrome.action.openPopup();"); + const popupTarget = await browser.waitForTarget( + (target) => + target.type() === "page" && target.url().endsWith("popup.html"), + { timeout: 5000 }, + ); + popupPage = await popupTarget.asPage(); + await popupPage!.waitForSelector("#checkboxDomainInput"); + const isEnabledForCurrentDomain = await popupPage!.$eval( + "#checkboxDomainInput", + (el) => (el as HTMLInputElement).checked, + ); + expect(isEnabledForCurrentDomain).toBe(true); + } finally { + if (popupPage && !popupPage.isClosed()) { + await popupPage.close(); + } + await setSettingAndWait(worker!, KEY_DOMAIN_LIST_MODE, "blackList"); + await setSettingAndWait(worker!, "domainBlackList", []); + } + }, 10000); + test("CKEditor 5 input initializes on test page", async () => { await gotoTestPage(page, { enableCkEditor: true }); page.bringToFront(); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 00000000..6845bd5b --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,91 @@ +import { jest } from "@jest/globals"; +import { + SETTINGS_DOMAIN_BLACKLIST, + addDomainToList, + isDomainOnList, + removeDomainFromList, +} from "../src/shared/utils"; +import type { SettingsManager } from "../src/shared/settingsManager"; + +function createSettingsManager(initialDomainList: unknown[]) { + const state: Record = { + [SETTINGS_DOMAIN_BLACKLIST]: initialDomainList, + }; + + const settings = { + get: jest.fn(async (key: string) => state[key]), + set: jest.fn(async (key: string, value: unknown) => { + state[key] = value; + }), + }; + + return { + state, + settings: settings as unknown as SettingsManager, + getMock: settings.get, + setMock: settings.set, + }; +} + +describe("shared utils domain list handling", () => { + test("isDomainOnList matches exact normalized host and not regex-like false positives", async () => { + const { settings } = createSettingsManager(["example.com"]); + + await expect(isDomainOnList(settings, "https://EXAMPLE.com/path")).resolves.toBe( + true, + ); + await expect(isDomainOnList(settings, "exampleXcom")).resolves.toBe(false); + }); + + test("isDomainOnList ignores invalid entries and still matches valid hosts", async () => { + const { settings } = createSettingsManager(["[", "localhost"]); + + await expect(isDomainOnList(settings, "localhost")).resolves.toBe(true); + }); + + test("addDomainToList stores normalized host and ignores invalid host input", async () => { + const { settings, state, setMock } = createSettingsManager([]); + + await addDomainToList(settings, "https://Example.COM/path?a=1"); + expect(state[SETTINGS_DOMAIN_BLACKLIST]).toEqual(["example.com"]); + expect(setMock).toHaveBeenCalledTimes(1); + + await addDomainToList(settings, "["); + expect(state[SETTINGS_DOMAIN_BLACKLIST]).toEqual(["example.com"]); + expect(setMock).toHaveBeenCalledTimes(1); + }); + + test("addDomainToList handles host:port/path input by keeping host only", async () => { + const { settings, state } = createSettingsManager([]); + + await addDomainToList(settings, "localhost:8080/path"); + expect(state[SETTINGS_DOMAIN_BLACKLIST]).toEqual(["localhost"]); + }); + + test("removeDomainFromList removes only exact normalized host match", async () => { + const { settings, state } = createSettingsManager([ + "example.com", + "exampleXcom", + "https://LOCALHOST:8080/path", + ]); + + await removeDomainFromList(settings, "exampleXcom"); + expect(state[SETTINGS_DOMAIN_BLACKLIST]).toEqual([ + "example.com", + "https://LOCALHOST:8080/path", + ]); + + await removeDomainFromList(settings, "localhost"); + expect(state[SETTINGS_DOMAIN_BLACKLIST]).toEqual(["example.com"]); + }); + + test("removeDomainFromList matches entries stored as URL by host", async () => { + const { settings, state, getMock } = createSettingsManager([ + "https://LOCALHOST/path", + ]); + + await removeDomainFromList(settings, "localhost"); + expect(state[SETTINGS_DOMAIN_BLACKLIST]).toEqual([]); + expect(getMock).toHaveBeenCalledWith(SETTINGS_DOMAIN_BLACKLIST); + }); +});