Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -27,7 +57,8 @@ export async function isDomainOnList(
settings: SettingsManager,
domainURL: string,
): Promise<boolean> {
if (!domainURL) {
const normalizedDomain = normalizeDomainHost(domainURL);
if (!normalizedDomain) {
return false;
}
try {
Expand All @@ -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;
}
}
Expand All @@ -54,12 +86,16 @@ export async function addDomainToList(
settings: SettingsManager,
domainURL: string,
): Promise<void> {
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)}`);
Expand All @@ -73,13 +109,18 @@ export async function removeDomainFromList(
settings: SettingsManager,
domainURL: string,
): Promise<void> {
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;
Expand Down
79 changes: 79 additions & 0 deletions tests/e2e/puppeteer-extension.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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("<!doctype html><html><body><p>domain test page</p></body></html>");
});
await new Promise<void>((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 () => {
Expand All @@ -406,6 +427,17 @@ describe("Chrome Extension E2E Test", () => {
});

afterAll(async () => {
if (domainTestServer?.listening) {
await new Promise<void>((resolve, reject) => {
domainTestServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
await browser.close();
});

Expand Down Expand Up @@ -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();
Expand Down
91 changes: 91 additions & 0 deletions tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
[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);
});
});
Loading