From e6eff75677a3b2af51b6cbc97b42991e91e802db Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Tue, 6 Aug 2019 00:07:45 +0200 Subject: [PATCH] Wildcard subdomains - e.g. *.google.com --- package.json | 2 +- src/css/popup.css | 5 + src/js/.eslintrc.js | 2 + src/js/background/assignManager.js | 258 +++++++++++-------- src/js/background/backgroundLogic.js | 12 + src/js/background/index.html | 2 + src/js/background/messageHandler.js | 8 +- src/js/background/utils.js | 106 ++++++++ src/js/background/wildcardManager.js | 69 +++++ src/js/popup.js | 78 +++++- test/features/external-webextensions.test.js | 2 +- test/features/wildcard.test.js | 58 +++++ test/helper.js | 9 + 13 files changed, 500 insertions(+), 111 deletions(-) create mode 100644 src/js/background/utils.js create mode 100644 src/js/background/wildcardManager.js create mode 100644 test/features/wildcard.test.js diff --git a/package.json b/package.json index 753f292a..90dce9ad 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "stylelint-config-standard": "^16.0.0", "stylelint-order": "^0.3.0", "web-ext": "^2.9.3", - "webextensions-jsdom": "^1.1.0" + "webextensions-jsdom": "^1.1.1" }, "homepage": "https://github.com/mozilla/multi-account-containers#readme", "license": "MPL-2.0", diff --git a/src/css/popup.css b/src/css/popup.css index d5f32958..5457ad2a 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -832,6 +832,11 @@ span ~ .panel-header-text { flex: 1; } +/* Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 */ +.assigned-sites-list .hostname .subdomain:hover { + text-decoration: underline; +} + .radio-choice > .radio-container { align-items: center; block-size: 29px; diff --git a/src/js/.eslintrc.js b/src/js/.eslintrc.js index f78079f9..5ca947a2 100644 --- a/src/js/.eslintrc.js +++ b/src/js/.eslintrc.js @@ -3,6 +3,8 @@ module.exports = { "../../.eslintrc.js" ], "globals": { + "utils": false, + "wildcardManager": false, "assignManager": true, "badge": true, "backgroundLogic": true, diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index b48db759..b206ea2e 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -6,115 +6,150 @@ const assignManager = { MENU_MOVE_ID: "move-to-new-window-container", OPEN_IN_CONTAINER: "open-bookmark-in-container-tab", storageArea: { - area: browser.storage.local, + area: new utils.NamedStore("siteContainerMap"), exemptedTabs: {}, - getSiteStoreKey(pageUrl) { - const url = new window.URL(pageUrl); - const storagePrefix = "siteContainerMap@@_"; - if (url.port === "80" || url.port === "443") { - return `${storagePrefix}${url.hostname}`; - } else { - return `${storagePrefix}${url.hostname}${url.port}`; + async matchUrl(pageUrl) { + const siteId = backgroundLogic.getSiteIdFromUrl(pageUrl); + + // Try exact match + let siteSettings = await this.get(siteId); + + if (!siteSettings) { + // Try wildcard match + const wildcard = await wildcardManager.match(siteId); + if (wildcard) { + siteSettings = await this.get(wildcard); + } } + + return siteSettings; }, - - setExempted(pageUrl, tabId) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - if (!(siteStoreKey in this.exemptedTabs)) { - this.exemptedTabs[siteStoreKey] = []; - } - this.exemptedTabs[siteStoreKey].push(tabId); + + create(siteId, userContextId, options = {}) { + const siteSettings = { userContextId, neverAsk:!!options.neverAsk }; + this._setTransientProperties(siteId, siteSettings, options.wildcard); + return siteSettings; }, - removeExempted(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - this.exemptedTabs[siteStoreKey] = []; + async get(siteId) { + const siteSettings = await this.area.get(siteId); + await this._loadTransientProperties(siteId, siteSettings); + return siteSettings; }, - isExempted(pageUrl, tabId) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - if (!(siteStoreKey in this.exemptedTabs)) { - return false; - } - return this.exemptedTabs[siteStoreKey].includes(tabId); - }, - - get(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - return new Promise((resolve, reject) => { - this.area.get([siteStoreKey]).then((storageResponse) => { - if (storageResponse && siteStoreKey in storageResponse) { - resolve(storageResponse[siteStoreKey]); - } - resolve(null); - }).catch((e) => { - reject(e); - }); - }); - }, - - set(pageUrl, data, exemptedTabIds) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - if (exemptedTabIds) { - exemptedTabIds.forEach((tabId) => { - this.setExempted(pageUrl, tabId); - }); + async set(siteSettings) { + const siteId = siteSettings.siteId; + const exemptedTabs = siteSettings.exemptedTabs; + const wildcard = siteSettings.wildcard; + + // Store exempted tabs + this.exemptedTabs[siteId] = exemptedTabs; + + // Store/remove wildcard mapping + if (wildcard && wildcard !== siteId) { + await wildcardManager.set(siteId, wildcard); + } else { + await wildcardManager.remove(siteId); } - return this.area.set({ - [siteStoreKey]: data - }); + + // Remove transient properties before storing + const cleanSiteSettings = Object.assign({}, siteSettings); + this._unsetTransientProperties(cleanSiteSettings); + + // Store assignment + return this.area.set(siteId, cleanSiteSettings); }, - remove(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); + async remove(siteId) { // When we remove an assignment we should clear all the exemptions - this.removeExempted(pageUrl); - return this.area.remove([siteStoreKey]); + delete this.exemptedTabs[siteId]; + // ...and also clear the wildcard mapping + await wildcardManager.remove(siteId); + + return this.area.remove(siteId); }, async deleteContainer(userContextId) { - const sitesByContainer = await this.getByContainer(userContextId); - this.area.remove(Object.keys(sitesByContainer)); + const siteSettingsById = await this.getByContainer(userContextId); + const siteIds = Object.keys(siteSettingsById); + + siteIds.forEach((siteId) => { + // When we remove an assignment we should clear all the exemptions + delete this.exemptedTabs[siteId]; + }); + + // ...and also clear the wildcard mappings + await wildcardManager.removeAll(siteIds); + + return this.area.removeAll(siteIds); }, async getByContainer(userContextId) { - const sites = {}; - const siteConfigs = await this.area.get(); - Object.keys(siteConfigs).forEach((key) => { + const siteSettingsById = await this.area.getSome((siteId, siteSettings) => { // For some reason this is stored as string... lets check them both as that - if (String(siteConfigs[key].userContextId) === String(userContextId)) { - const site = siteConfigs[key]; - // In hindsight we should have stored this - // TODO file a follow up to clean the storage onLoad - site.hostname = key.replace(/^siteContainerMap@@_/, ""); - sites[key] = site; - } + return String(siteSettings.userContextId) === String(userContextId); }); - return sites; + await this._loadTransientPropertiesForAll(siteSettingsById); + return siteSettingsById; + }, + + async _loadTransientProperties(siteId, siteSettings) { + if (siteId && siteSettings) { + const wildcard = await wildcardManager.get(siteId); + const exemptedTabs = this.exemptedTabs[siteId]; + this._setTransientProperties(siteId, siteSettings, wildcard, exemptedTabs); + } + }, + + async _loadTransientPropertiesForAll(siteSettingsById) { + const siteIds = Object.keys(siteSettingsById); + if (siteIds.length > 0) { + const siteIdsToWildcards = await wildcardManager.getAll(siteIds); + siteIds.forEach((siteId) => { + const siteSettings = siteSettingsById[siteId]; + const wildcard = siteIdsToWildcards[siteId]; + const exemptedTabs = this.exemptedTabs[siteId]; + this._setTransientProperties(siteId, siteSettings, wildcard, exemptedTabs); + }); + } + }, + + _setTransientProperties(siteId, siteSettings, wildcard, exemptedTabs = []) { + siteSettings.siteId = siteId; + siteSettings.hostname = siteId; + siteSettings.wildcard = wildcard; + siteSettings.exemptedTabs = exemptedTabs; + }, + + _unsetTransientProperties(siteSettings) { + delete siteSettings.siteId; + delete siteSettings.hostname; + delete siteSettings.wildcard; + delete siteSettings.exemptedTabs; } }, - _neverAsk(m) { + async _neverAsk(m) { const pageUrl = m.pageUrl; - if (m.neverAsk === true) { - // If we have existing data and for some reason it hasn't been deleted etc lets update it - this.storageArea.get(pageUrl).then((siteSettings) => { - if (siteSettings) { - siteSettings.neverAsk = true; - this.storageArea.set(pageUrl, siteSettings); - } - }).catch((e) => { - throw e; - }); + const neverAsk = m.neverAsk; + if (neverAsk === true) { + const siteSettings = await this.storageArea.matchUrl(pageUrl); + if (siteSettings && !siteSettings.neverAsk) { + siteSettings.neverAsk = true; + await this.storageArea.set(siteSettings); + } } }, - // We return here so the confirm page can load the tab when exempted async _exemptTab(m) { const pageUrl = m.pageUrl; - this.storageArea.setExempted(pageUrl, m.tabId); - return true; + const tabId = m.tabId; + const siteSettings = await this.storageArea.matchUrl(pageUrl); + if (siteSettings && siteSettings.exemptedTabs.indexOf(tabId) === -1) { + siteSettings.exemptedTabs.push(tabId); + await this.storageArea.set(siteSettings); + } }, // Before a request is handled by the browser we decide if we should route through a different container @@ -125,7 +160,7 @@ const assignManager = { this.removeContextMenu(); const [tab, siteSettings] = await Promise.all([ browser.tabs.get(options.tabId), - this.storageArea.get(options.url) + this.storageArea.matchUrl(options.url) ]); let container; try { @@ -142,8 +177,8 @@ const assignManager = { } const userContextId = this.getUserContextIdFromCookieStore(tab); if (!siteSettings - || userContextId === siteSettings.userContextId - || this.storageArea.isExempted(options.url, tab.id)) { + || siteSettings.userContextId === userContextId + || siteSettings.exemptedTabs.includes(tab.id)) { return {}; } const removeTab = backgroundLogic.NEW_TAB_PAGES.has(tab.url) @@ -367,7 +402,14 @@ const assignManager = { return true; }, - async _setOrRemoveAssignment(tabId, pageUrl, userContextId, remove) { + _determineAssignmentMatchesUrl(siteSettings, url) { + const siteId = backgroundLogic.getSiteIdFromUrl(url); + if (siteSettings.siteId === siteId) { return true; } + if (siteSettings.wildcard && siteId.endsWith(siteSettings.wildcard)) { return true; } + return false; + }, + + async _setOrRemoveAssignment(tabId, pageUrl, userContextId, remove, options = {}) { let actionName; // https://github.com/mozilla/testpilot-containers/issues/626 @@ -375,43 +417,53 @@ const assignManager = { // the value to a string for accurate checking userContextId = String(userContextId); + const siteId = backgroundLogic.getSiteIdFromUrl(pageUrl); if (!remove) { + const siteSettings = this.storageArea.create(siteId, userContextId, options); + + // Auto exempt all tabs that exist for this hostname that are not in the same container const tabs = await browser.tabs.query({}); - const assignmentStoreKey = this.storageArea.getSiteStoreKey(pageUrl); - const exemptedTabIds = tabs.filter((tab) => { - const tabStoreKey = this.storageArea.getSiteStoreKey(tab.url); - /* Auto exempt all tabs that exist for this hostname that are not in the same container */ - if (tabStoreKey === assignmentStoreKey && - this.getUserContextIdFromCookieStore(tab) !== userContextId) { - return true; - } - return false; + siteSettings.exemptedTabs = tabs.filter((tab) => { + if (!this._determineAssignmentMatchesUrl(siteSettings, tab.url)) { return false; } + if (this.getUserContextIdFromCookieStore(tab) === userContextId) { return false; } + return true; }).map((tab) => { return tab.id; }); - - await this.storageArea.set(pageUrl, { - userContextId, - neverAsk: false - }, exemptedTabIds); + + await this.storageArea.set(siteSettings); actionName = "added"; } else { - await this.storageArea.remove(pageUrl); + await this.storageArea.remove(siteId); actionName = "removed"; } - browser.tabs.sendMessage(tabId, { - text: `Successfully ${actionName} site to always open in this container` - }); + if (!options.silent) { + browser.tabs.sendMessage(tabId, { + text: `Successfully ${actionName} site to always open in this container` + }); + } const tab = await browser.tabs.get(tabId); this.calculateContextMenu(tab); }, + + async _setOrRemoveWildcard(tabId, pageUrl, userContextId, wildcard) { + // Get existing settings, so we can preserve neverAsk property + const siteId = backgroundLogic.getSiteIdFromUrl(pageUrl); + const siteSettings = await this.storageArea.get(siteId); + const neverAsk = siteSettings && siteSettings.neverAsk; + + // Remove assignment + await this._setOrRemoveAssignment(tabId, pageUrl, userContextId, true, {silent:true}); + // Add assignment + await this._setOrRemoveAssignment(tabId, pageUrl, userContextId, false, {silent:true, wildcard:wildcard, neverAsk:neverAsk}); + }, async _getAssignment(tab) { const cookieStore = this.getUserContextIdFromCookieStore(tab); // Ensure we have a cookieStore to assign to if (cookieStore && this.isTabPermittedAssign(tab)) { - return await this.storageArea.get(tab.url); + return await this.storageArea.matchUrl(tab.url); } return false; }, diff --git a/src/js/background/backgroundLogic.js b/src/js/background/backgroundLogic.js index 5d71fecf..05bb13e7 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -329,5 +329,17 @@ const backgroundLogic = { cookieStoreId(userContextId) { return `firefox-container-${userContextId}`; + }, + + // A URL host string that is used to identify a site assignment, e.g.: + // www.example.com + // www.example.com:8080 + getSiteIdFromUrl(pageUrl) { + const url = new window.URL(pageUrl); + if (url.port === "" || url.port === "80" || url.port === "443") { + return `${url.hostname}`; + } else { + return `${url.hostname}:${url.port}`; + } } }; \ No newline at end of file diff --git a/src/js/background/index.html b/src/js/background/index.html index e167f0b6..57b685e2 100644 --- a/src/js/background/index.html +++ b/src/js/background/index.html @@ -13,7 +13,9 @@ "js/background/messageHandler.js", ] --> + + diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index 9578e6e2..e8863dd7 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -37,6 +37,12 @@ const messageHandler = { return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value); }); break; + case "setOrRemoveWildcard": + // Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 + response = browser.tabs.get(m.tabId).then((tab) => { + return assignManager._setOrRemoveWildcard(tab.id, m.url, m.userContextId, m.wildcard); + }); + break; case "sortTabs": backgroundLogic.sortTabs(); break; @@ -91,7 +97,7 @@ const messageHandler = { if (typeof message.url === "undefined") { throw new Error("Missing message.url"); } - response = assignManager.storageArea.get(message.url); + response = assignManager.storageArea.matchUrl(message.url); break; default: throw new Error("Unknown message.method"); diff --git a/src/js/background/utils.js b/src/js/background/utils.js new file mode 100644 index 00000000..5985ab8c --- /dev/null +++ b/src/js/background/utils.js @@ -0,0 +1,106 @@ +const utils = { // eslint-disable-line no-unused-vars + + // Copy object and remove keys with predicate + filterObj(obj, predicate) { + if (obj && typeof obj !== "object") { throw new Error(`Invalid arg: ${obj}`); } + if (!obj) { return {}; } + return Object.assign({}, ...Object.entries(obj).map(([k,v]) => { + if (predicate(k, v)) { + return { [k]: v }; + } else { + return null; + } + })); + }, + + // Store data in a named storage area. + // + // (Note that all data for all stores is stored in the same single storage area, + // but this class provides accessor methods to get/set only the data that applies + // to one specific named store, as identified in the constructor.) + NamedStore: class { + constructor(name) { + this.prefix = `${name}@@_`; + } + + _storeKeyForKey(key) { + if (Array.isArray(key)) { + return key.map(oneKey => oneKey.startsWith(this.prefix) ? oneKey : `${this.prefix}${oneKey}`); + } else if (key) { + return key.startsWith(this.prefix) ? key : `${this.prefix}${key}`; + } else { + return null; + } + } + + _keyForStoreKey(storeKey) { + if (Array.isArray(storeKey)) { + return storeKey.map(oneStoreKey => oneStoreKey.startsWith(this.prefix) ? oneStoreKey.substring(this.prefix.length) : null); + } else if (storeKey) { + return storeKey.startsWith(this.prefix) ? storeKey.substring(this.prefix.length) : null; + } else { + return null; + } + } + + get(key) { + if (typeof key !== "string") { return Promise.reject(new Error(`Invalid arg: ${key}`)); } + const storeKey = this._storeKeyForKey(key); + return new Promise((resolve, reject) => { + browser.storage.local.get([storeKey]).then((storageResponse) => { + if (storeKey in storageResponse) { + resolve(storageResponse[storeKey]); + } else { + resolve(null); + } + }).catch((e) => { + reject(e); + }); + }); + } + + getAll(keys) { + if (keys && !Array.isArray(keys)) { return Promise.reject(new Error(`Invalid arg: ${keys}`)); } + const storeKeys = this._storeKeyForKey(keys); + return new Promise((resolve, reject) => { + browser.storage.local.get(storeKeys).then((storageResponse) => { + if (storageResponse) { + resolve(Object.assign({}, ...Object.entries(storageResponse).map(([oneStoreKey, data]) => { + const key = this._keyForStoreKey(oneStoreKey); + return key ? { [key]: data } : null; + }))); + } else { + resolve({}); + } + }).catch((e) => { + reject(e); + }); + }); + } + + async getSome(predicate) { + const all = await this.getAll(); + return utils.filterObj(all, predicate); + } + + set(key, data) { + if (typeof key !== "string") { return Promise.reject(new Error(`Invalid arg: ${key}`)); } + const storeKey = this._storeKeyForKey(key); + return browser.storage.local.set({ + [storeKey]: data + }); + } + + remove(key) { + if (typeof key !== "string") { return Promise.reject(new Error(`Invalid arg: ${key}`)); } + const storeKey = this._storeKeyForKey(key); + return browser.storage.local.remove(storeKey); + } + + removeAll(keys) { + if (keys && !Array.isArray(keys)) { return Promise.reject(new Error(`Invalid arg: ${keys}`)); } + const storeKeys = this._storeKeyForKey(keys); + return browser.storage.local.remove(storeKeys); + } + } +}; \ No newline at end of file diff --git a/src/js/background/wildcardManager.js b/src/js/background/wildcardManager.js new file mode 100644 index 00000000..900056c1 --- /dev/null +++ b/src/js/background/wildcardManager.js @@ -0,0 +1,69 @@ +/** + Manages mappings of Site Host <-> Wildcard Host. + + E.g. drive.google.com <-> google.com + + Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 + */ +const wildcardManager = { // eslint-disable-line no-unused-vars + bySite: new utils.NamedStore("siteToWildcardMap"), + byWildcard: new utils.NamedStore("wildcardToSiteMap"), + + // Site -> Wildcard + get(site) { + return this.bySite.get(site); + }, + + async getAll(sites) { + return this.bySite.getAll(sites); + }, + + async set(site, wildcard) { + // Remove existing site -> wildcard + const oldSite = await this.byWildcard.get(wildcard); + if (oldSite === site) { return; } // Wildcard already set + if (oldSite) { await this.bySite.remove(oldSite); } + + // Set new mappings site <-> wildcard + await this.bySite.set(site, wildcard); + await this.byWildcard.set(wildcard, site); + }, + + async remove(site) { + const wildcard = await this.bySite.get(site); + if (!wildcard) { return; } + + await this.bySite.remove(site); + await this.byWildcard.remove(wildcard); + }, + + async removeAll(sites) { + const data = await this.bySite.getAll(sites); + const existingSites = Object.keys(data); + const existingWildcards = Object.values(data); + + await this.bySite.removeAll(existingSites); + await this.byWildcard.removeAll(existingWildcards); + }, + + // Site -> Site that owns Wildcard + async match(site) { + // Keep stripping subdomains off site domain until match a wildcard domain + do { + // Use the ever-shortening site hostname as if it is a wildcard + const siteHavingWildcard = await this.byWildcard.get(site); + if (siteHavingWildcard) { return siteHavingWildcard; } + } while ((site = this._removeSubdomain(site))); + return null; + }, + + _removeSubdomain(site) { + const indexOfDot = site.indexOf("."); + if (indexOfDot < 0) { + return null; + } else { + return site.substring(indexOfDot + 1); + } + } +}; + diff --git a/src/js/popup.js b/src/js/popup.js index 64dca459..bd7f2f7e 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -368,6 +368,17 @@ const Logic = { }); }, + // Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 + setOrRemoveWildcard(tabId, url, userContextId, wildcard) { + return browser.runtime.sendMessage({ + method: "setOrRemoveWildcard", + tabId, + url, + userContextId, + wildcard + }); + }, + generateIdentityName() { const defaultName = "Container #"; const ids = []; @@ -393,7 +404,7 @@ const Logic = { getCurrentPanelElement() { const panelItem = this._panels[this._currentPanel]; return document.querySelector(this.getPanelSelector(panelItem)); - }, + } }; // P_ONBOARDING_1: First page for Onboarding. @@ -1030,16 +1041,38 @@ Logic.registerPanel(P_CONTAINER_EDIT, { const assumedUrl = `https://${site.hostname}/favicon.ico`; trElement.innerHTML = escaped`
-
- ${site.hostname} -
+
`; trElement.getElementsByClassName("favicon")[0].appendChild(Utils.createFavIconElement(assumedUrl)); - const deleteButton = trElement.querySelector(".delete-assignment"); + trElement.querySelector(".hostname").appendChild(this.assignmentElement(site)); + const that = this; + + // Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 + trElement.querySelectorAll(".subdomain").forEach(function(subdomainLink) { + subdomainLink.addEventListener("click", async (e) => { + const userContextId = Logic.currentUserContextId(); + // Wildcard hostname is stored in id attribute + const wildcard = e.target.id; + if (wildcard) { + // Remove wildcard from other site that has same wildcard + Object.values(assignments).forEach((site) => { + if (site.wildcard === wildcard) { delete site.wildcard; } + }); + site.wildcard = wildcard; + } else { + delete site.wildcard; + } + const currentTab = await Logic.currentTab(); + Logic.setOrRemoveWildcard(currentTab.id, assumedUrl, userContextId, wildcard); + that.showAssignedContainers(assignments); + }); + }); + + const deleteButton = trElement.querySelector(".delete-assignment"); Logic.addEnterHandler(deleteButton, async () => { const userContextId = Logic.currentUserContextId(); // Lets show the message to the current tab @@ -1049,11 +1082,46 @@ Logic.registerPanel(P_CONTAINER_EDIT, { delete assignments[siteKey]; that.showAssignedContainers(assignments); }); + trElement.classList.add("container-info-tab-row", "clickable"); tableElement.appendChild(trElement); }); } }, + + // Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 + assignmentElement(site) { + const result = document.createElement("span"); + + // Remove wildcard subdomain + if (site.wildcard && site.wildcard !== site.hostname) { + result.appendChild(this.assignmentSubdomainLink(null, "___")); + result.appendChild(document.createTextNode(".")); + } + + // Add wildcard subdomain + let hostname = site.wildcard ? site.wildcard : site.hostname; + let indexOfDot; + while ((indexOfDot = hostname.indexOf(".")) >= 0) { + const subdomain = hostname.substring(0, indexOfDot); + hostname = hostname.substring(indexOfDot + 1); + result.appendChild(this.assignmentSubdomainLink(hostname, subdomain)); + result.appendChild(document.createTextNode(".")); + } + + // Root domain + result.appendChild(document.createTextNode(hostname)); + + return result; + }, + + assignmentSubdomainLink(wildcard, text) { + const result = document.createElement("a"); + if (wildcard) { result.id = wildcard; } + result.className = "subdomain"; + result.appendChild(document.createTextNode(text)); + return result; + }, initializeRadioButtons() { const colorRadioTemplate = (containerColor) => { diff --git a/test/features/external-webextensions.test.js b/test/features/external-webextensions.test.js index 30e6b492..3515825a 100644 --- a/test/features/external-webextensions.test.js +++ b/test/features/external-webextensions.test.js @@ -25,7 +25,7 @@ describe("External Webextensions", () => { const [promise] = background.browser.runtime.onMessageExternal.addListener.yield(message, sender); const answer = await promise; - expect(answer).to.deep.equal({ + expect(answer).to.deep.include({ userContextId: "1", neverAsk: false }); diff --git a/test/features/wildcard.test.js b/test/features/wildcard.test.js new file mode 100644 index 00000000..3e715c97 --- /dev/null +++ b/test/features/wildcard.test.js @@ -0,0 +1,58 @@ +// Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 +describe("Wildcard Subdomains Feature", () => { + const url1 = "http://www.example.com"; + const url2 = "http://mail.example.com"; + + let activeTab; + beforeEach(async () => { + activeTab = await helper.browser.initializeWithTab({ + cookieStoreId: "firefox-container-1", + url: url1 + }); + }); + + describe("click the 'Always open in' checkbox in the popup", () => { + beforeEach(async () => { + // popup click to set assignment for activeTab.url + await helper.popup.clickElementById("container-page-assigned"); + }); + + describe("click the assigned URL's subdomain to convert it to a wildcard", () => { + beforeEach(async () => { + await helper.popup.setWildcard(activeTab, "example.com"); + }); + + describe("open new Tab with a different subdomain in the default container", () => { + let newTab; + beforeEach(async () => { + // new Tab opening activeTab.url in default container + newTab = await helper.browser.openNewTab({ + cookieStoreId: "firefox-default", + url: url2 + }, { + options: { + webRequestError: true // because request is canceled due to reopening + } + }); + }); + + it("should open the confirm page", async () => { + // should have created a new tab with the confirm page + background.browser.tabs.create.should.have.been.calledWithMatch({ + url: "moz-extension://fake/confirm-page.html?" + + `url=${encodeURIComponent(url2)}` + + `&cookieStoreId=${activeTab.cookieStoreId}`, + cookieStoreId: undefined, + openerTabId: null, + index: 2, + active: true + }); + }); + + it("should remove the new Tab that got opened in the default container", () => { + background.browser.tabs.remove.should.have.been.calledWith(newTab.id); + }); + }); + }); + }); +}); diff --git a/test/helper.js b/test/helper.js index 2704bacb..3098b448 100644 --- a/test/helper.js +++ b/test/helper.js @@ -11,6 +11,8 @@ module.exports = { } }, popup: { + // Required to access variables, because nyc messes up 'eval' + script: "function evalScript(v) { return eval(v); }", jsdom: { beforeParse(window) { window.browser.storage.local.set({ @@ -39,6 +41,13 @@ module.exports = { async clickLastMatchingElementByQuerySelector(querySelector) { await popup.helper.clickElementByQuerySelectorAll(querySelector, "last"); + }, + + // Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 + async setWildcard(tab, wildcard) { + const Logic = popup.window.evalScript("Logic"); + const userContextId = Logic.userContextId(tab.cookieStoreId); + await Logic.setOrRemoveWildcard(tab.id, tab.url, userContextId, wildcard); } } };